|
33 | 33 | import tools.dynamia.commons.logger.LoggingService; |
34 | 34 | import tools.dynamia.crud.CrudPage; |
35 | 35 | import tools.dynamia.domain.query.DataPaginator; |
| 36 | +import tools.dynamia.domain.query.QueryConditions; |
36 | 37 | import tools.dynamia.domain.query.QueryParameters; |
37 | 38 | import tools.dynamia.domain.services.CrudService; |
38 | 39 | import tools.dynamia.domain.util.QueryBuilder; |
|
50 | 51 |
|
51 | 52 | import java.util.List; |
52 | 53 | import java.util.Map; |
| 54 | +import java.util.Set; |
53 | 55 |
|
54 | 56 | /** |
55 | 57 | * REST controller that handles CRUD operations on entities exposed through the application's |
@@ -202,6 +204,7 @@ private ResponseEntity<String> readAll(String path, HttpServletRequest request) |
202 | 204 | query.where(pageParams); |
203 | 205 | } |
204 | 206 | parseConditions(query, readDescriptor); |
| 207 | + applyRequestFilters(request, query, readDescriptor); |
205 | 208 |
|
206 | 209 | int pageSize = getParameterNumber(request, "size"); |
207 | 210 | int currentPage = getParameterNumber(request, "page"); |
@@ -473,6 +476,118 @@ public static void parseConditions(QueryBuilder query, ViewDescriptor descriptor |
473 | 476 | } |
474 | 477 | } |
475 | 478 |
|
| 479 | + /** |
| 480 | + * Applies dynamic field filters derived from HTTP query parameters to the given {@link QueryBuilder}. |
| 481 | + * |
| 482 | + * <p>Any request parameter whose name does not start with {@code _} and is not a reserved |
| 483 | + * pagination keyword ({@code page}, {@code size}) is treated as a potential field filter. |
| 484 | + * The filter value is matched against the entity field registered in the {@link ViewDescriptor} |
| 485 | + * and the appropriate {@link QueryConditions} condition is selected based on the field's Java type:</p> |
| 486 | + * |
| 487 | + * <ul> |
| 488 | + * <li>{@link String} — {@code LIKE} with auto-searchable wildcard wrapping</li> |
| 489 | + * <li>{@link Number} / numeric primitives — exact equality ({@code =})</li> |
| 490 | + * <li>{@link Boolean} / {@code boolean} — exact equality ({@code =})</li> |
| 491 | + * <li>{@link Enum} subtypes — exact equality ({@code =}) using {@link Enum#valueOf}</li> |
| 492 | + * <li>Any other type — skipped; not safe to cast without type information</li> |
| 493 | + * </ul> |
| 494 | + * |
| 495 | + * <p>Parameters for fields not present in the descriptor are silently ignored.</p> |
| 496 | + * |
| 497 | + * <p><b>Usage example:</b></p> |
| 498 | + * <pre>{@code GET /api/users?name=john&status=ACTIVE&age=30 }</pre> |
| 499 | + * |
| 500 | + * @param request the current HTTP request carrying the filter parameters |
| 501 | + * @param query the query builder to augment with filter conditions |
| 502 | + * @param descriptor the view descriptor used to resolve field metadata; if {@code null} this |
| 503 | + * method does nothing |
| 504 | + */ |
| 505 | + public static void applyRequestFilters(HttpServletRequest request, QueryBuilder query, ViewDescriptor descriptor) { |
| 506 | + if (descriptor == null) { |
| 507 | + return; |
| 508 | + } |
| 509 | + |
| 510 | + // Parameter names that are reserved for pagination / internal use and must not be treated as filters. |
| 511 | + Set<String> RESERVED_PARAMS = Set.of("page", "size"); |
| 512 | + |
| 513 | + request.getParameterMap().forEach((paramName, values) -> { |
| 514 | + // Skip internal (_) params and pagination keywords |
| 515 | + if (paramName.startsWith("_") || RESERVED_PARAMS.contains(paramName) || values.length == 0) { |
| 516 | + return; |
| 517 | + } |
| 518 | + |
| 519 | + Field field = descriptor.getField(paramName); |
| 520 | + if (field == null) { |
| 521 | + return; // Unknown field — ignore silently |
| 522 | + } |
| 523 | + |
| 524 | + Class<?> fieldType = field.getFieldClass(); |
| 525 | + if (fieldType == null && field.getPropertyInfo() != null) { |
| 526 | + fieldType = field.getPropertyInfo().getType(); |
| 527 | + } |
| 528 | + if (fieldType == null) { |
| 529 | + return; // Cannot determine type — skip |
| 530 | + } |
| 531 | + |
| 532 | + String rawValue = values[0]; |
| 533 | + if (rawValue == null || rawValue.isBlank()) { |
| 534 | + return; |
| 535 | + } |
| 536 | + |
| 537 | + try { |
| 538 | + if (fieldType == String.class) { |
| 539 | + // LIKE with auto-searchable wildcard (e.g., "john" → "%john%") |
| 540 | + query.and(paramName, QueryConditions.like(rawValue)); |
| 541 | + |
| 542 | + } else if (Number.class.isAssignableFrom(fieldType) || fieldType.isPrimitive() && fieldType != boolean.class) { |
| 543 | + // Numeric equality — parse to the right numeric type |
| 544 | + Object numericValue = parseNumber(rawValue, fieldType); |
| 545 | + if (numericValue != null) { |
| 546 | + query.and(paramName, QueryConditions.eq(numericValue)); |
| 547 | + } |
| 548 | + |
| 549 | + } else if (fieldType == Boolean.class || fieldType == boolean.class) { |
| 550 | + boolean boolValue = "true".equalsIgnoreCase(rawValue) || "1".equals(rawValue); |
| 551 | + query.and(paramName, QueryConditions.eq(boolValue)); |
| 552 | + |
| 553 | + } else if (fieldType.isEnum()) { |
| 554 | + @SuppressWarnings({"unchecked", "rawtypes"}) |
| 555 | + Object enumValue = Enum.valueOf((Class<Enum>) fieldType, rawValue.toUpperCase()); |
| 556 | + query.and(paramName, QueryConditions.eq(enumValue)); |
| 557 | + |
| 558 | + } |
| 559 | + // Date, entity references, collections, etc. are intentionally skipped: |
| 560 | + // they require more complex handling and are out of scope for simple URL filtering. |
| 561 | + } catch (Exception e) { |
| 562 | + LoggingService.get(RestNavigationController.class) |
| 563 | + .warn("Ignoring filter param '" + paramName + "=" + rawValue + "': " + e.getMessage()); |
| 564 | + } |
| 565 | + }); |
| 566 | + } |
| 567 | + |
| 568 | + /** |
| 569 | + * Parses a raw string value into the target numeric type. |
| 570 | + * |
| 571 | + * <p>Supports {@link Integer}, {@code int}, {@link Long}, {@code long}, |
| 572 | + * {@link Double}, {@code double}, {@link Float}, {@code float}, |
| 573 | + * {@link Short}, {@code short}, {@link Byte}, {@code byte}, |
| 574 | + * and {@link java.math.BigDecimal}.</p> |
| 575 | + * |
| 576 | + * @param raw the raw string to parse |
| 577 | + * @param targetType the target numeric {@link Class} |
| 578 | + * @return the parsed number, or {@code null} if the type is not supported |
| 579 | + */ |
| 580 | + private static Object parseNumber(String raw, Class<?> targetType) { |
| 581 | + if (targetType == Integer.class || targetType == int.class) return Integer.parseInt(raw); |
| 582 | + if (targetType == Long.class || targetType == long.class) return Long.parseLong(raw); |
| 583 | + if (targetType == Double.class || targetType == double.class) return Double.parseDouble(raw); |
| 584 | + if (targetType == Float.class || targetType == float.class) return Float.parseFloat(raw); |
| 585 | + if (targetType == Short.class || targetType == short.class) return Short.parseShort(raw); |
| 586 | + if (targetType == Byte.class || targetType == byte.class) return Byte.parseByte(raw); |
| 587 | + if (targetType == java.math.BigDecimal.class) return new java.math.BigDecimal(raw); |
| 588 | + return null; |
| 589 | + } |
| 590 | + |
476 | 591 | /** |
477 | 592 | * Reads an integer request parameter by name. Returns {@code 0} if the parameter |
478 | 593 | * is absent or cannot be parsed as an integer. |
|
0 commit comments