Core runtime module of the dev-persistence framework. Provides the service abstractions, dynamic query building engine, and reflection-based filter binding that all other modules depend on.
- Overview
- Configuration
- Service Layer
- Filter Objects
- QueryParameter
- Native SQL Queries
- QueryJpql
- Custom Row Mapping
- JdbcTemplateService
- Abstract Search Controllers
- Real-world examples
common-jpa-service replaces the typical pattern of writing boilerplate query methods for each entity. Instead, you define a typed filter object whose non-null fields are automatically translated into JPQL WHERE conditions at runtime. The JPQL query strings themselves are pre-built at compile time by the processor-jpa-service annotation processor and stored in a generated QueryJpqlImpl class.
At runtime the call chain is:
YourService.findByFilter(queryParameter)
→ JpaServiceImpl.findByFilter()
→ BaseJpaService (builds dynamic JPQL / native SQL)
→ QueryJpqlImpl (provides pre-built strings and condition maps)
→ EntityManager / JpaRepository
Enable the framework in your Spring Boot application:
@SpringBootApplication
@EnableJpaService
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}@EnableJpaService imports EnableJpaServiceConfiguration, which registers the ReflectionCommons bean used internally for parameter introspection.
JpaService<T, ID> is the main interface your service interfaces extend. It provides:
CRUD
| Method | Description |
|---|---|
save(T entity) |
Insert or update |
update(T entity) |
Update existing entity |
delete(T entity) |
Delete one entity |
deleteAll(Collection<T>) |
Bulk delete |
findById(ID id) |
Find by primary key |
findAll() |
Return all rows |
count() |
Total row count |
existsById(ID id) |
Check existence |
Filter-based queries
| Method | Description |
|---|---|
findByFilter(QueryParameter<T,ID>) |
List of entities matching filter |
countByFilter(QueryParameter<T,ID>) |
Count matching entities |
deleteByFilter(QueryParameter<T,ID>) |
Delete matching entities |
singleResultByFilter(QueryParameter<T,ID>) |
Single entity (exception if multiple) |
mapFindByFilter(QueryParameter<T,ID>) |
Results as PersistenceMap<K,V> |
mapKeyFindByFilter(QueryParameter<T,ID>) |
Results keyed by a field |
mapKeyListFindByFilter(QueryParameter<T,ID>) |
Results grouped by a key field |
The key parameter used by mapKeyFindByFilter and mapKeyListFindByFilter is a
dot-notation field path resolved via reflection — it can traverse relationships:
QueryParameter<Product, Long> qp = new QueryParameter<>();
qp.addParameter("active", true);
// Keyed by the entity's own primary key
PersistenceMap<Long, Product> byId = productService.mapFindByFilter(qp);
// Keyed by a related field: Product → category → categoryId
PersistenceMap<Long, Product> byCategory =
productService.mapKeyFindByFilter(qp, Long.class, "category.categoryId");
// If multiple products share the same key, the last one wins.
// Grouped by the same field
PersistenceMap<Long, List<Product>> grouped =
productService.mapKeyListFindByFilter(qp, Long.class, "category.categoryId");Native SQL queries
| Method | Description |
|---|---|
findNativeByFilter(NativeQueryParameter<K,ID>) |
List mapped from native SQL Tuple results |
findNativeByFilter(NativeQueryParameter<K,ID>, JpaRowMapper<K>) |
List via custom row mapper |
Concrete service classes extend JpaServiceImpl<T, ID> and implement two abstract methods:
@Service
@Transactional
@QueryBuilder(
distinct = true,
conditions = {
@ConditionBuilder(field = "product.name", operation = OperationType.LIKE, parameter = "name",
upperLower = UpperLowerType.LOWER),
@ConditionBuilder(field = "product.active", operation = OperationType.EQUAL, parameter = "active")
},
jpaOrder = {
@JpqlOrderBuilder(key = "name", order = "product.name")
}
)
public class ProductServiceImpl
extends JpaServiceImpl<Product, Long>
implements ProductService {
@Autowired
private ProductRepository productRepository;
@PersistenceContext
private EntityManager entityManager;
@Override
protected JpaRepository<Product, Long> getJpaRepository() {
return productRepository;
}
@Override
protected EntityManager getEntityManager() {
return entityManager;
}
}The annotation processor reads @QueryBuilder and generates ProductQueryJpqlImpl at compile time. Spring autowires it into JpaServiceImpl as the QueryJpql<Product> bean.
Create a filter DTO by extending BaseParameter. Any non-null field is automatically added to the WHERE clause at runtime.
public class ProductFilter extends BaseParameter {
private String name;
@LikeString(likeType = LikeType.LEFT_RIGHT, upperLowerType = UpperLowerType.LOWER)
private String description;
@DateFilter(addDay = 1)
private Date expiresAfter;
@ConditionTrigger
private Boolean deletedAtIsNull;
// getters / setters
}Usage:
ProductFilter filter = new ProductFilter();
filter.setName("Widget");
filter.addOrderBy("name", OrderType.ASC);
filter.setPageSize(20);
filter.setPageNumber(0);
QueryParameter<Product, Long> qp = new QueryParameter<>(filter);
List<Product> results = productService.findByFilter(qp);Built-in fields (annotated with @IgnoreMapping so they are not treated as query conditions):
| Field | Description |
|---|---|
pageSize |
Number of results per page |
pageNumber |
Zero-based page number |
orderBy |
List of OrderBy(sortKey, OrderType) items |
IDFilterParameter<ID> extends BaseParameter and adds a typed List<ID> id field that maps to an IN (...) condition on the entity's primary key. It is the standard base class when you need to filter by a list of IDs alongside other criteria.
Override the JSON property name to match your domain naming:
public class ApplicationFilter extends IDFilterParameter<Integer> {
@LikeString(upperLowerType = UpperLowerType.UPPER, likeType = LikeType.NONE)
private String name;
private List<Integer> idEnvironment;
private List<Integer> idProject;
// Expose the inherited 'id' field under the domain name in JSON
@Override
@JsonProperty("idApplication")
public List<Integer> getId() { return super.getId(); }
@Override
@JsonProperty("idApplication")
public void setId(List<Integer> id) { super.setId(id); }
// Convenience method
public void addIdApplication(Integer... ids) {
if (ids != null) this.getId().addAll(Arrays.asList(ids));
}
}Shifts a date/time value by a configurable offset before binding it as a parameter. The most common use case is implementing from/to date range filters.
Supported types: Date, Calendar, Timestamp, Instant, LocalDate, LocalDateTime, OffsetDateTime.
public class ExceptionAuditFilter extends IDFilterParameter<Integer> {
@DateFilter // no offset — used as lower bound (>=)
private Calendar updateTimeFrom;
@DateFilter(addDay = 1) // adds 1 day — used as upper bound (<) making the range inclusive
private Calendar updateTimeTo;
@DateFilter
private Calendar createTimeFrom;
@DateFilter(addDay = 1)
private Calendar createTimeTo;
}Parameters: addYear, addMonth, addWeek, addDay, addHour, addMinute, addSecond (all default 0, negative values subtract).
The matching @ConditionBuilder entries on the service use OperationType.GREATER_EQUAL / OperationType.LESS to produce the range:
@ConditionBuilder(field = "entity.updateTime", operation = OperationType.GREATER_EQUAL, parameter = "updateTimeFrom"),
@ConditionBuilder(field = "entity.updateTime", operation = OperationType.LESS, parameter = "updateTimeTo"),Wraps a String value in SQL LIKE wildcards.
@LikeString(likeType = LikeType.LEFT_RIGHT, upperLowerType = UpperLowerType.LOWER)
private String lastName;
// generates: AND LOWER(e.lastName) LIKE LOWER(:lastName) → '%smith%'
@LikeString(upperLowerType = UpperLowerType.UPPER, likeType = LikeType.NONE)
private String name;
// generates: AND UPPER(e.name) LIKE :name (no wildcards — caller supplies them if needed)LikeType values: LEFT (%value), RIGHT (value%), LEFT_RIGHT (%value%), NONE (no wildcard added).
UpperLowerType values: NONE, UPPER, LOWER.
Marks a Boolean field as a trigger for a value-less JPQL condition (IS NULL / IS NOT NULL). When the field is true, the engine calls QueryParameter.addNullable(fieldName), activating the matching @ConditionBuilder that uses OperationType.IS_NULL or OperationType.IS_NOT_NULL (no parameter value is bound). When the field is false or null, the condition is skipped.
public class ServiceRestFilter extends BaseParameter {
@ConditionTrigger
private Boolean httpMethodIsNull; // true → AND e.httpMethod IS NULL
@ConditionTrigger
private Boolean httpMethodIsNotNull; // true → AND e.httpMethod IS NOT NULL
}Service side:
@QueryBuilder(
conditions = {
@ConditionBuilder(field = "e.httpMethod", operation = OperationType.IS_NULL, parameter = "httpMethodIsNull"),
@ConditionBuilder(field = "e.httpMethod", operation = OperationType.IS_NOT_NULL, parameter = "httpMethodIsNotNull")
}
)Collection / array fields are mapped to IN conditions automatically when the matching @ConditionBuilder uses OperationType.IN or OperationType.NOT_IN — no annotation required.
Overrides the default JPQL parameter name (which defaults to the Java field name).
@FieldMapping("firstName")
private String userFirstName;
// JPQL uses :firstName instead of :userFirstNamePrevents a field from being added to the parameter map. Used on fields that should not generate WHERE conditions (e.g., display-only fields, flags set programmatically by hooks).
Forces a field to be included in the WHERE clause even when its value is null (generates an IS NULL condition).
Routes a field's value to a named zone in a native SQL template. Zones allow selective activation of WHERE blocks in native queries:
@LikeString(upperLowerType = UpperLowerType.UPPER, likeType = LikeType.NONE)
@ConditionsZones(@ConditionsZone(key = "appCondition"))
private String email;
// This field is only added when the "appCondition" zone is referenced in the native queryMultiple zones can be specified:
@ConditionsZones({
@ConditionsZone(key = "zone1"),
@ConditionsZone(key = "zone2")
})
private String value;QueryParameter<T, ID> is the main input object for filter-based queries. There are two ways to populate it:
Via a typed filter object:
QueryParameter<Product, Long> qp = new QueryParameter<>(filter);The framework introspects the BaseParameter subclass via reflection and extracts all non-null fields into the internal parameter map.
Manually:
QueryParameter<Product, Long> qp = new QueryParameter<>();
qp.addParameter("categoryId", 5L);
qp.addNullable("deletedAt"); // always append: AND e.deletedAt IS NULLaddNullable(name) always appends the condition regardless of value — useful for IS NULL checks.
Manual QueryParameter is the standard pattern inside hook implementations:
// Inside an AfterFind hook — build a separate query to load related data
QueryParameter<ExAssUserRole, ExAssUserRolePK> queryUserRole = new QueryParameter<>();
queryUserRole.addParameter(ExAssUserRoleQueryJpql.idUser, response.getData().getId());
ExAssUserRole userRole = exAssUserRoleService.singleResultByFilter(queryUserRole);For queries that cannot be expressed in JPQL (vendor-specific syntax, complex joins, tuple comparisons), use native SQL with NativeQueryParameter.
Define the native query in a .sql file (or inline string) and use ${zoneName} placeholders for dynamic WHERE blocks:
SELECT p.id, p.name, p.price
FROM product p
${mainZone}
ORDER BY p.nameNativeQueryParameter<ProductDto, Long> nqp = new NativeQueryParameter<>(ProductDto.class);
nqp.addCondition("mainZone", "active", "AND p.active = :active", true);
nqp.addParameter("active", true);
List<ProductDto> results = productService.findNativeByFilter(nqp);| Method | Description |
|---|---|
addCondition(zone, param, condition, value) |
Add a condition to a named zone |
addParameter(name, value) |
Add a raw named parameter |
setPageSize(int) |
Pagination |
setPageNumber(int) |
Pagination |
addOrderBy(sortKey, OrderType) |
Sorting |
Zones allow the same SQL template to have multiple independently activated WHERE blocks. Each zone corresponds to a ${zoneName} placeholder in the SQL template. A condition bound to a zone is only injected when the corresponding parameter is non-null and the query references that zone.
Step 1 — Native SQL template with zone placeholder (defined as a constant on the service interface)
public interface ExApplicationService extends JpaService<ExApplication, Integer> {
String NAME_APPLICATION =
"select distinct ea.name\n" +
"from ex_application ea \n" +
"join ex_project ep on ea.id_project = ep.id_project \n" +
"left join (ex_ass_project_user eapu\n" +
" join ex_user eu on eu.id_user = eapu.id_user) on ep.id_project = eapu.id_project\n" +
"${appCondition}"; // zone placeholder — replaced with conditions at runtime
}When email is present the zone expands to and upper(eu.email) = :email. When absent the zone is removed entirely, returning all applications.
Step 2 — @QueryBuilder with customNativeConditions binding the SQL fragment to the zone
@Service
@Transactional
@QueryBuilder(
customNativeConditions = {
@CustomConditionBuilder(
condition = " and upper(eu.email) = :email ", // raw SQL injected into the zone
parameter = "email", // activates only when 'email' is non-null
keys = "appCondition" // targets this specific zone
)
},
nativeOrder = {
@NativeOrderBuilder(key = "name", order = "ea.name")
}
)
public class ExApplicationServiceImpl
extends JpaServiceImpl<ExApplication, Integer>
implements ExApplicationService {
// ...
}Step 3a — Filter class with @ConditionsZones on a field
When the zone parameter comes from a filter body field, annotate the field with @ConditionsZones:
public class ApplicationFilter extends IDFilterParameter<Integer> {
@LikeString(upperLowerType = UpperLowerType.UPPER, likeType = LikeType.NONE)
@ConditionsZones(@ConditionsZone(key = "appCondition"))
private String email;
// Routed to "appCondition" zone — not used as a standard JPQL condition
}Step 3b — Controller method parameter with @ConditionsZones
When the zone parameter comes from a method argument (e.g., @AuthenticationPrincipal), annotate the parameter directly:
@PostMapping("/grant/all/names")
@ApiQuery(value = ExApplicationService.NAME_APPLICATION, orderBy = @DefaultOrderBy("name"))
CollectionResponse<ApplicationModel> searchName(
@AuthenticationPrincipal
@Param("email") // bind as parameter named "email"
@ConditionsZones(@ConditionsZone(key = "appCondition")) // route to the appCondition zone
@LikeString(upperLowerType = UpperLowerType.UPPER, likeType = LikeType.NONE)
ExUserSecurity exUserSecurity);The framework extracts the authenticated user's email field, routes it to zone appCondition, and the SQL becomes:
select distinct ea.name
from ex_application ea
join ex_project ep on ea.id_project = ep.id_project
left join (ex_ass_project_user eapu
join ex_user eu on eu.id_user = eapu.id_user) on ep.id_project = eapu.id_project
and upper(eu.email) = :emailIf email is null the ${appCondition} placeholder is removed and all rows are returned.
initWhere = true (default on @ConditionsZone) tells the framework to prepend WHERE or AND before the first condition in the zone automatically.
QueryJpql<T> is the abstract holder for all pre-built JPQL/SQL strings for a specific entity. You never create subclasses manually — they are generated by processor-jpa-service at compile time from the @QueryBuilder annotation on the *ServiceImpl class.
For each @QueryBuilder-annotated class the processor generates two files:
| Generated file | Purpose |
|---|---|
*QueryJpql.java |
Interface with public static final String constants for every parameter and sort key name |
*QueryJpqlImpl.java |
@Component that extends QueryJpql<T>, implements the interface, and holds all pre-built strings and maps |
Given this @QueryBuilder on ExApplicationServiceImpl:
@QueryBuilder(
conditions = {
@ConditionBuilder(field = "exApplication.name",
operation = OperationType.IN, parameter = "applicationsName"),
@ConditionBuilder(field = "exApplication.exProject.idProject",
operation = OperationType.IN, parameter = "idProjectGrant"),
@ConditionBuilder(field = "exApplication.name",
operation = OperationType.NOT_IN, parameter = "notInName"),
@ConditionBuilder(field = "exApplication.exProject.idProject",
operation = OperationType.IN, parameter = "idProject"),
@ConditionBuilder(field = "exApplication.exEnvironment.idEnvironment",
operation = OperationType.IN, parameter = "idEnvironment"),
},
jpaOrder = {
@JpqlOrderBuilder(key = "desApplicationType",
order = "exApplication.exApplicationType.desApplicationType")
},
customNativeConditions = {
@CustomConditionBuilder(condition = " and upper(eu.email)=:email ",
parameter = "email", keys = "appCondition")
},
nativeOrder = {
@NativeOrderBuilder(key = "name", order = "ea.name")
}
)
public class ExApplicationServiceImpl extends JpaServiceImpl<ExApplication, Integer> ...The processor produces:
public interface ExApplicationQueryJpql {
// One constant per @ConditionBuilder parameter + base fields from the entity
String applicationsName = "applicationsName";
String idProjectGrant = "idProjectGrant";
String notInName = "notInName";
String idProject = "idProject";
String idEnvironment = "idEnvironment";
String name = "name";
String email = "email";
String id = "id";
String idApplication = "idApplication";
String updateTime = "updateTime";
String updateTimeFrom = "updateTimeFrom";
String updateTimeTo = "updateTimeTo";
String createTime = "createTime";
String createTimeFrom = "createTimeFrom";
String createTimeTo = "createTimeTo";
// ...
// Sort key constants (prefixed ord_)
String ord_desApplicationType = "desApplicationType";
String ord_name = "name";
}These constants are used in hook implementations to avoid hardcoded strings:
// In GrantHandlerFindRequest:
parameters.addParameter(ExApplicationQueryJpql.idProjectGrant, projectIds);
// ↑ same string that appears in the generated WHERE condition@Component
public class ExApplicationQueryJpqlImpl
extends QueryJpql<ExApplication>
implements ExApplicationQueryJpql {
// ── 1. Static JPQL base strings ─────────────────────────────────────────
// FROM clause with all mandatory join fetches (derived from the entity's @ManyToOne fields)
private static final String FROM_BY_FILTER =
" From ExApplication exApplication " +
" join fetch exApplication.exProject exProject " +
" join fetch exApplication.exEnvironment exEnvironment " +
" join fetch exApplication.exApplicationType exApplicationType ";
private static final String SELECT_BY_FILTER = "select distinct exApplication" + FROM_BY_FILTER;
private static final String COUNT_BY_FILTER = "select distinct count(exApplication)" + FROM_BY_FILTER;
private static final String SELECT_ID_BY_FILTER = "select distinct exApplication.idApplication " + FROM_BY_FILTER;
private static final String DELETE_BY_FILTER = "delete from ExApplication exApplication ";
// ── 2. MAP_CONDITIONS — JPQL SELECT/COUNT conditions ────────────────────
// Key = parameter name (matches @ConditionBuilder.parameter)
// Value = JPQL condition fragment appended to the WHERE clause when the parameter is non-null
private static final Map<String,String> MAP_CONDITIONS = getMapConditions();
private static Map<String,String> getMapConditions() {
Map<String,String> map = getMapBaseConditions(); // fields from the entity itself
// Fields from @ConditionBuilder — navigating through relationships via join-fetched aliases:
map.put(applicationsName, " and ((exApplication.name) in (:applicationsName) )");
map.put(notInName, " and ((exApplication.name) not in (:notInName) )");
map.put(idProject, " and (exProject.idProject in (:idProject) )");
map.put(idEnvironment, " and (exEnvironment.idEnvironment in (:idEnvironment) )");
map.put(idProjectGrant, " and (exProject.idProject in (:idProjectGrant) )");
// ...
return map;
}
// Base conditions — automatically generated from entity fields + IDFilterParameter
private static Map<String,String> getMapBaseConditions() {
Map<String,String> map = new HashMap<>();
map.put(name, " and upper(exApplication.name) like :name ");
map.put(id, " and exApplication.idApplication in (:id) ");
map.put(idApplication, " and exApplication.idApplication in (:idApplication) ");
map.put(updateTimeFrom, " and :updateTimeFrom<=exApplication.updateTime ");
map.put(updateTimeTo, " and exApplication.updateTime<=:updateTimeTo ");
map.put(createTimeFrom, " and :createTimeFrom<=exApplication.createTime ");
map.put(createTimeTo, " and exApplication.createTime<=:createTimeTo ");
// ...
return map;
}
// ── 3. MAP_DELETE_CONDITIONS — JPQL DELETE conditions ───────────────────
// Identical structure to MAP_CONDITIONS but without join-fetched aliases
// (DELETE queries cannot use join fetch, so relationships are navigated inline)
private static final Map<String,String> MAP_DELETE_CONDITIONS = getMapDeleteConditions();
private static Map<String,String> getMapDeleteConditions() {
Map<String,String> map = getMapBaseConditions();
map.put(idProject, " and (exApplication.exProject.idProject in (:idProject) )");
map.put(idProjectGrant, " and (exApplication.exProject.idProject in (:idProjectGrant) )");
// notice the full path: exApplication.exProject.idProject vs exProject.idProject in SELECT
// ...
return map;
}
// ── 4. MAP_NATIVE_CONDITIONS — per-zone native SQL conditions ────────────
// Outer key = zone name (matches @CustomConditionBuilder.keys)
// Inner map = parameter name → SQL fragment injected into that zone
private static final Map<String,Map<String,String>> MAP_NATIVE_CONDITIONS = getMapNativeConditions();
private static Map<String,Map<String,String>> getMapNativeConditions() {
Map<String,Map<String,String>> map = new HashMap<>();
map.put("appCondition", getAppCondition()); // one entry per zone
return map;
}
private static Map<String,String> getAppCondition() {
Map<String,String> map = new HashMap<>();
map.put(email, " and upper(eu.email)=:email "); // SQL fragment for this parameter
return map;
}
// ── 5. MAP_NATIVE_ORDERS — native SQL sort keys ──────────────────────────
// Key = sort key string from @NativeOrderBuilder.key
// Value = SQL column expression used in ORDER BY
private static final Map<String,String> MAP_NATIVE_ORDERS = getMapNativeOrders();
private static Map<String,String> getMapNativeOrders() {
Map<String,String> map = new HashMap<>();
map.put(ord_name, " ea.name ");
return map;
}
// ── 6. MAP_JPA_ORDERS — JPQL sort keys ──────────────────────────────────
// Auto-generated for every field reachable via join-fetched aliases
// + explicit entries from @JpqlOrderBuilder
private static final Map<String,String> MAP_JPA_ORDERS = getMapJpaOrders();
private static Map<String,String> getMapJpaOrders() {
Map<String,String> map = new HashMap<>();
// Auto-generated from join-fetched paths:
map.put("exApplication.name", "exApplication.name");
map.put("exProject.idProject", "exProject.idProject");
map.put("exEnvironment.idEnvironment", "exEnvironment.idEnvironment");
map.put("exEnvironment.envName", "exEnvironment.envName");
map.put("exProject.prjName", "exProject.prjName");
// Explicit entry from @JpqlOrderBuilder:
map.put(ord_desApplicationType, " exApplicationType.desApplicationType ");
// ...
return map;
}
// ── 7. mapOneToMany() — conditional LEFT JOIN FETCH ──────────────────────
// Called lazily on first access. Registers joins that are added to the SELECT
// query ONLY when the corresponding parameter is non-null in the filter.
// This prevents Cartesian products for unneeded collections.
@Override
public void mapOneToMany() {
addJoinOneToMany(idApplicationServer,
" left join fetch exApplication.exApplicationServers exApplicationServers ");
addJoinOneToMany(idServiceRest,
" left join fetch exApplication.exServiceRests exServiceRests ");
// Each entry: if parameter 'idApplicationServer' is non-null → add the JOIN FETCH
}
}At query execution time JpaServiceImpl does the following:
- Iterates the active parameters from
QueryParameter. - For each parameter name, looks it up in
MAP_CONDITIONS. - If found, appends the condition fragment to the base JPQL string.
- Checks
mapOneToMany()— if the parameter has a registered JOIN FETCH, adds it to the FROM clause. - Builds the final ORDER BY from
MAP_JPA_ORDERSusing the sort keys inQueryParameter.orderBy.
For native queries the same process runs against MAP_NATIVE_CONDITIONS[zoneName], replacing ${zoneName} in the SQL template with the active condition fragments for that zone.
QueryJpql is autowired into JpaServiceImpl as a generic Spring bean and is not used directly by application code.
For JPA Tuple-based native queries that need custom row-to-object mapping, implement JpaRowMapper<K>:
@FunctionalInterface
public interface JpaRowMapper<K> {
void rowMapper(List<K> result, Tuple row, int i);
}The framework calls rowMapper once per row in the result set, passing the accumulating list, the current Tuple, and the zero-based row index.
List<ProductDto> results = productService.findNativeByFilter(nqp, (list, row, i) -> {
ProductDto dto = new ProductDto();
dto.setId(row.get("id", Long.class));
dto.setName(row.get("name", String.class));
list.add(dto);
});For pure JDBC queries (via JdbcTemplateService), implement JdbcRowMapper<K>:
@FunctionalInterface
public interface JdbcRowMapper<K> {
void rowMapper(List<K> list, ResultSet row, int i);
}Works identically to JpaRowMapper but receives a JDBC ResultSet instead of a JPA Tuple:
List<ReportRow> rows = reportService.findNativeByFilter(nqp, (list, rs, i) -> {
ReportRow row = new ReportRow();
row.setId(rs.getLong("id"));
row.setAmount(rs.getBigDecimal("amount"));
list.add(row);
});When a native query result model contains a field that cannot be mapped automatically from the column value (e.g., an enum, a JSON blob, or a composite type), implement ResultMapper<T> and bind it to the field with @ResultMapping.
// 1. Implement the converter
public class StatusMapper implements ResultMapper<StatusEnum> {
@Override
public StatusEnum mapToData(Map<String, Object> map) {
return StatusEnum.fromCode((String) map.get("status_code"));
}
}
// 2. Annotate the field on the result model
public class OrderSummary {
private Long id;
private String name;
@ResultMapping(StatusMapper.class) // custom conversion for this field
private StatusEnum status;
}The reflection engine skips automatic binding for fields annotated with @ResultMapping and delegates to the specified ResultMapper instead.
ResultMapper attributes:
| Attribute | Type | Description |
|---|---|---|
value |
Class<? extends ResultMapper<?>> |
The converter class to use; instantiated by the framework |
Marks a field to be excluded entirely from the automatic result-set mapping. Use this for fields that are computed at service level or populated after the query rather than from the SQL result.
public class ProductSummary {
private String name;
private BigDecimal price;
@IgnoreResultSet // populated by a post-processing step, not from the query
private String displayLabel;
}For pure JDBC access (bypassing JPA), extend JdbcTemplateServiceImpl:
@Service
public class ReportServiceImpl extends JdbcTemplateServiceImpl<ReportDto, Long>
implements ReportService {
// inherited: findByFilter, countByFilter with NamedParameterJdbcTemplate
}JdbcTemplateService<T, ID> mirrors the JpaService API but executes queries via NamedParameterJdbcTemplate.
common-jpa-service ships two abstract Spring MVC controller base classes that wire a JpaService to standard REST endpoints out of the box. Subclass them when you need a traditional @RestController class instead of (or alongside) the proxy-api-controller dynamic proxy approach.
BaseSearchController<E, ID, M, P, MM> provides three protected methods (search, count, single-result) already wired to the injected JpaService. Subclasses expose them as HTTP endpoints and provide the ModelMapper:
@RestController
@RequestMapping("/api/products")
public class ProductController
extends BaseSearchController<Product, Long, ProductDto, ProductFilter, ProductMapper> {
@PostMapping("/search")
@ResponseBody
@Transactional
public CollectionResponse<ProductDto> search(@RequestBody ProductFilter filter) throws Exception {
return super.findByFilter(filter);
}
@PostMapping("/count")
@ResponseBody
@Transactional
public ObjectResponse<Long> count(@RequestBody ProductFilter filter) throws Exception {
return super.countByFilter(filter);
}
@PostMapping("/search/single")
@ResponseBody
@Transactional
public ObjectResponse<ProductDto> single(@RequestBody ProductFilter filter) throws Exception {
return super.singleResultFindByFilter(filter);
}
@Override
protected ProductMapper modelMapper() {
return this.modelMapper; // inject via @Autowired
}
}Generic type parameters:
| Parameter | Role |
|---|---|
E |
JPA entity type |
ID |
Primary-key type |
M |
Full DTO / model type |
P |
Filter type (BaseParameter subclass) |
MM |
ModelMapper<E, M> implementation |
PerformanceSearchController<E, ID, M, PM, P> extends BaseSearchController and adds a second search endpoint (/performance/search) that returns a lighter PM model. Use this when list views only need a subset of the entity's fields and you want to avoid transferring the full model payload.
@RestController
@RequestMapping("/api/products")
public class ProductController
extends PerformanceSearchController<Product, Long, ProductDto, ProductSummaryDto, ProductFilter> {
// POST /api/products/search → List<ProductDto> (full model)
// POST /api/products/performance/search → List<ProductSummaryDto> (lightweight)
// POST /api/products/count → ObjectResponse<Long>
// POST /api/products/search/single-result → ObjectResponse<ProductDto>
}All four endpoints are inherited and require no additional code in the subclass.
Generic type parameters:
| Parameter | Role |
|---|---|
E |
JPA entity type |
ID |
Primary-key type |
M |
Full DTO / model type (for /search) |
PM |
Lightweight performance model (for /performance/search) |
P |
Filter type |
ModelMapper<E, M> is the single-method interface used by BaseSearchController to convert entities to models:
public interface ModelMapper<E, M> {
M convertToModel(E entity);
}PerformanceModelMapper<E, M, PM> extends it with a second conversion method for the lightweight model:
public interface PerformanceModelMapper<E, M extends BaseModel<?>, PM extends BaseModel<?>>
extends ModelMapper<E, M> {
PM convertToPerformanceModel(E entity);
}Typically implemented with MapStruct:
@Mapper(componentModel = "spring")
public interface ProductMapper extends PerformanceModelMapper<Product, ProductDto, ProductSummaryDto> {
@Override
ProductDto convertToModel(Product entity);
@Override
ProductSummaryDto convertToPerformanceModel(Product entity);
}Field paths in @ConditionBuilder follow JPQL dot-notation and can traverse multiple levels of relationships. All conditions are inactive by default and activate only when the corresponding filter field is non-null.
@Service
@Transactional
@QueryBuilder(conditions = {
// Direct field on the entity
@ConditionBuilder(field = "exExceptionAudit.exServiceRest.httpMethod",
operation = OperationType.IN, parameter = "method"),
// LIKE with case normalization
@ConditionBuilder(field = "exExceptionAudit.exServiceRest.path",
operation = OperationType.LIKE, parameter = "path",
upperLower = UpperLowerType.UPPER),
// Boolean equality
@ConditionBuilder(field = "exExceptionAudit.exServiceRest.async",
operation = OperationType.EQUAL, parameter = "async"),
// Date range — lower bound
@ConditionBuilder(field = "exExceptionAudit.updateTime",
operation = OperationType.GREATER_EQUAL, parameter = "updateTimeFrom"),
// Date range — upper bound (note: @DateFilter(addDay=1) shifts the value on the filter side)
@ConditionBuilder(field = "exExceptionAudit.updateTime",
operation = OperationType.LESS, parameter = "updateTimeTo"),
// Navigation through 3 levels
@ConditionBuilder(field = "exExceptionAudit.exServiceRest.exApplication.idApplication",
operation = OperationType.IN, parameter = "idApplication"),
// Navigation through 4 levels
@ConditionBuilder(field = "exExceptionAudit.exServiceRest.exApplication.exApplicationType.idApplicationType",
operation = OperationType.IN, parameter = "idApplicationType"),
// Same parameter name, different navigation path — for grant-based filtering
@ConditionBuilder(field = "exExceptionAudit.exServiceRest.exApplication.exProject.idProject",
operation = OperationType.IN, parameter = "idProjectGrant"),
},
jpaOrder = {
@JpqlOrderBuilder(key = "path", order = "exExceptionAudit.exServiceRest.path"),
@JpqlOrderBuilder(key = "envName", order = "exExceptionAudit.exServiceRest.exApplication.exEnvironment.envName"),
@JpqlOrderBuilder(key = "prjName", order = "exExceptionAudit.exServiceRest.exApplication.exProject.prjName")
})
public class ExExceptionAuditServiceImpl
extends JpaServiceImpl<ExExceptionAudit, Integer>
implements ExExceptionAuditService {
@Autowired
private ExExceptionAuditRepository exExceptionAuditRepository;
@PersistenceContext
private EntityManager entityManager;
@Override
protected EntityManager getEntityManager() { return this.entityManager; }
@Override
protected JpaRepository<ExExceptionAudit, Integer> getJpaRepository() {
return this.exExceptionAuditRepository;
}
}When a filter field must activate a condition only in a native SQL query (not JPQL), use @CustomConditionBuilder with keys:
@Service
@Transactional
@QueryBuilder(
conditions = {
@ConditionBuilder(field = "exApplication.name",
operation = OperationType.IN, parameter = "applicationsName"),
@ConditionBuilder(field = "exApplication.exProject.idProject",
operation = OperationType.IN, parameter = "idProjectGrant"),
},
jpaOrder = {
@JpqlOrderBuilder(key = "desApplicationType",
order = "exApplication.exApplicationType.desApplicationType")
},
customNativeConditions = {
// Activates only when "email" parameter is present AND the query references zone "appCondition"
@CustomConditionBuilder(
condition = " and upper(eu.email) = :email ",
parameter = "email",
keys = "appCondition"
)
},
nativeOrder = {
@NativeOrderBuilder(key = "name", order = "ea.name")
}
)
public class ExApplicationServiceImpl
extends JpaServiceImpl<ExApplication, Integer>
implements ExApplicationService {
@Autowired
private ExApplicationRepository exApplicationRepository;
@PersistenceContext
private EntityManager entityManager;
@Override
protected EntityManager getEntityManager() { return this.entityManager; }
@Override
protected JpaRepository<ExApplication, Integer> getJpaRepository() {
return this.exApplicationRepository;
}
}A realistic filter showing combined use of @DateFilter for date ranges, @LikeString for text search, @ConditionsZones for zone routing, and IDFilterParameter as base class:
@SuppressWarnings("serial")
public class ExceptionAuditFilter extends IDFilterParameter<Integer> {
// Text search fields — all normalised to UPPERCASE before comparison
@LikeString(upperLowerType = UpperLowerType.UPPER)
private String path;
@LikeString(upperLowerType = UpperLowerType.UPPER)
private String className;
@LikeString(upperLowerType = UpperLowerType.UPPER)
private String methodName;
// Date range — from (no shift) / to (shifted +1 day for inclusive upper bound)
@DateFilter
private Calendar updateTimeFrom;
@DateFilter(addDay = 1)
private Calendar updateTimeTo;
@DateFilter
private Calendar createTimeFrom;
@DateFilter(addDay = 1)
private Calendar createTimeTo;
// List/IN conditions
private List<Integer> idApplication;
private List<Integer> idApplicationType;
private List<Integer> idProgressType;
private List<String> method;
// Boolean conditions
private Boolean async;
private Boolean scheduled;
// Field for zone-based native SQL — not in standard JPQL path
@LikeString(likeType = LikeType.NONE, upperLowerType = UpperLowerType.UPPER)
@ConditionsZones(@ConditionsZone(key = "appCondition"))
private String email;
// Rename inherited 'id' to domain name in JSON
@Override
@JsonProperty("idExceptionAudit")
public void setId(List<Integer> id) { super.setId(id); }
}When you need to query from inside a service or hook (not from a controller), build QueryParameter manually:
// Lookup by a single known parameter
QueryParameter<ExAssUserRole, ExAssUserRolePK> qp = new QueryParameter<>();
qp.addParameter("idUser", userId);
ExAssUserRole result = exAssUserRoleService.singleResultByFilter(qp);
// Lookup with pagination and sort
QueryParameter<ExApplication, Integer> qp2 = new QueryParameter<>();
qp2.addParameter("idProject", projectId);
qp2.addParameter("active", true);
qp2.addOrderBy("name", OrderType.ASC);
qp2.setPageSize(50);
qp2.setPageNumber(0);
List<ExApplication> apps = exApplicationService.findByFilter(qp2);
// IS NULL check — add the parameter name to nullables
QueryParameter<ExApplication, Integer> qp3 = new QueryParameter<>();
qp3.addNullable("deletedAt"); // generates: AND e.deletedAt IS NULL
List<ExApplication> active = exApplicationService.findByFilter(qp3);The parameter names must match the names declared in @ConditionBuilder(parameter = "...") on the service implementation. Use the generated *QueryJpql constants to avoid hardcoded strings:
qp.addParameter(ExApplicationQueryJpql.idProjectGrant, projectIds);TupleParameter represents a multi-column row-value comparison for use in IN clauses. It is the standard way to express conditions like (col1, col2) IN ((v1a, v2a), (v1b, v2b)) in JPQL or native SQL.
Requirements: at least two parameter names must be declared (fewer than two throws JpaServiceException at construction time).
// Create a tuple parameter with two columns
TupleParameter tp = new TupleParameter(new String[]{"productId", "warehouseId"});
// Add one or more row tuples to compare against
tp.setObjects(new Object[]{1L, 10L});
tp.setObjects(new Object[]{2L, 10L});
// Add to the query parameter
QueryParameter<Inventory, Long> qp = new QueryParameter<>();
qp.addParameter("productWarehouse", tp);In combination with a @CustomConditionBuilder the generated condition is injected at runtime:
@QueryBuilder(
customConditions = {
@CustomConditionBuilder(
condition = "and (inventory.productId, inventory.warehouseId) in (:productWarehouse)",
parameter = "productWarehouse"
)
}
)
public class InventoryServiceImpl extends JpaServiceImpl<Inventory, Long> implements InventoryService { ... }@TupleComparison is the declarative equivalent: annotate a TupleParameter field on a BaseParameter subclass so the reflection engine populates and routes it automatically.
public class InventoryFilter extends BaseParameter {
@TupleComparison({"productId", "warehouseId"})
private TupleParameter productWarehouse;
// getters / setters
}@TupleComparison attributes:
| Attribute | Type | Description |
|---|---|---|
value |
String[] |
Named parameters forming the tuple; must contain at least two elements |