From f5961548e2063d54171c4e569dd412d09e010f81 Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Fri, 8 Aug 2025 23:04:12 -0700 Subject: [PATCH] Added exclude API to DotPathQL, improved design with better SoC --- README.md | 62 ++- .../io/github/trackerforce/DotPathQL.java | 368 ++---------------- .../io/github/trackerforce/ExclusionNode.java | 27 ++ .../io/github/trackerforce/PathCommon.java | 301 ++++++++++++++ .../io/github/trackerforce/PathExclude.java | 179 +++++++++ .../io/github/trackerforce/PathFilter.java | 105 +++++ .../ExcludeTypeClassRecordTest.java | 118 ++++++ .../trackerforce/ExcludeTypeClassTest.java | 30 ++ ...st.java => FilterTypeClassRecordTest.java} | 71 ++-- ...lassTest.java => FilterTypeClassTest.java} | 14 +- .../fixture/clazz/Occupation.java | 1 + .../fixture/clazz/UserDetail.java | 8 +- .../fixture/record/Occupation.java | 3 +- .../fixture/record/UserDetail.java | 8 +- 14 files changed, 877 insertions(+), 418 deletions(-) create mode 100644 src/main/java/io/github/trackerforce/ExclusionNode.java create mode 100644 src/main/java/io/github/trackerforce/PathCommon.java create mode 100644 src/main/java/io/github/trackerforce/PathExclude.java create mode 100644 src/main/java/io/github/trackerforce/PathFilter.java create mode 100644 src/test/java/io/github/trackerforce/ExcludeTypeClassRecordTest.java create mode 100644 src/test/java/io/github/trackerforce/ExcludeTypeClassTest.java rename src/test/java/io/github/trackerforce/{TypeClassRecordTest.java => FilterTypeClassRecordTest.java} (85%) rename src/test/java/io/github/trackerforce/{TypeClassTest.java => FilterTypeClassTest.java} (87%) diff --git a/README.md b/README.md index 982ef7f..8d63114 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ The `DotPathQL` is the core component of this project that allows you to extract ## Features - 🎯 **Selective Property Extraction**: Extract only the properties you need +- 🚫 **Property Exclusion**: Exclude specific properties and return everything else - πŸ” **Deep Nested Support**: Navigate through multiple levels of object nesting - πŸ“‹ **Collection Handling**: Process Lists, Arrays, and other Collections - πŸ—ΊοΈ **Map Support**: Handle both simple and complex Map structures @@ -34,33 +35,30 @@ The `DotPathQL` is the core component of this project that allows you to extract ``` -### Basic Usage +### Filter Usage ```java DotPathQL filterUtil = new DotPathQL(); // Filter specific properties from an object -List filterPaths = List.of( +Map result = filterUtil.filter(userObject, List.of( "username", "address.street", "address.city" -); - -Map result = filterUtil.filter(userObject, filterPaths); +)); ``` -### Example Output +### Exclude Usage -Given an input object with complex nested structure, the filter will return: +```java +DotPathQL filterUtil = new DotPathQL(); -```json -{ - "username": "john_doe", - "address": { - "street": "123 Main St", - "city": "Springfield" - } -} +// Exclude specific properties and return everything else +Map result = filterUtil.exclude(userObject, List.of( + "password", + "ssn", + "address.country" +)); ``` ## Supported Data Structures @@ -134,6 +132,40 @@ List> lightweightData = users.stream() .collect(Collectors.toList()); ``` +### Data Privacy and Security +Remove sensitive information while preserving the rest of the data structure: + +```java +// Exclude sensitive fields from user profiles +List sensitiveFields = List.of( + "password", + "ssn", + "creditCard.number", + "address.country" // Remove specific nested fields +); + +Map publicProfile = filterUtil.exclude(userObject, sensitiveFields); +``` + +### API Response Exclusion +Create APIs where clients can specify which fields to exclude: + +```java +@GetMapping("/users/{id}") +public Map getUser( + @PathVariable Long id, + @RequestParam(required = false) List exclude +) { + User user = userService.findById(id); + + if (exclude != null && !exclude.isEmpty()) { + return filterUtil.exclude(user, exclude); + } + + return filterUtil.filter(user, Collections.emptyList()); // Return all fields +} +``` + ### Report Generation Extract specific data points for reports: diff --git a/src/main/java/io/github/trackerforce/DotPathQL.java b/src/main/java/io/github/trackerforce/DotPathQL.java index a3b67f1..e62d47a 100644 --- a/src/main/java/io/github/trackerforce/DotPathQL.java +++ b/src/main/java/io/github/trackerforce/DotPathQL.java @@ -1,9 +1,8 @@ package io.github.trackerforce; -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.util.*; +import java.util.Collections; +import java.util.List; +import java.util.Map; /** * Utility class for filtering objects based on specified paths. @@ -15,13 +14,15 @@ @SuppressWarnings("unchecked") public class DotPathQL { - private final List defaultFilterPaths; + private final PathFilter pathFilter; + private final PathExclude pathExclude; /** * Constructs a DotPathQL instance with an empty list of default filter paths. */ public DotPathQL() { - this.defaultFilterPaths = new ArrayList<>(); + pathFilter = new PathFilter(); + pathExclude = new PathExclude(); } /** @@ -35,15 +36,22 @@ public DotPathQL() { * @return a map containing the filtered properties */ public Map filter(T source, List filterPaths) { - Map result = new LinkedHashMap<>(); - List expandedPaths = expandGroupedPaths(filterPaths); - expandedPaths.addAll(0, defaultFilterPaths); - - for (String path : expandedPaths) { - addPathToResult(result, source, path); - } + return pathFilter.filter(source, filterPaths); + } - return result; + /** + * Excludes the given paths from the source object and returns the remaining structure. + * Works as the inverse of {@link #filter(Object, List)} – instead of selecting only + * specific paths, it returns all properties except the excluded ones. + * Supports the same grouped path syntax (e.g. "locations[home.street,work.city]"). + * + * @param the type of the source object + * @param source the source object to extract from + * @param excludePaths list of dot paths to exclude + * @return a map containing all properties except the excluded ones + */ + public Map exclude(T source, List excludePaths) { + return pathExclude.exclude(source, excludePaths); } /** @@ -100,337 +108,7 @@ public Object[] arrayFrom(Map source, String property) { * @param paths the list of default filter paths to add */ public void addDefaultFilterPaths(List paths) { - defaultFilterPaths.addAll(paths); - } - - /** - * Expands grouped paths like "parent[child1.prop,child2.prop]" into individual paths. - * - * @param filterPaths the list of paths that may contain grouped syntax - * @return a list of expanded individual paths - */ - private List expandGroupedPaths(List filterPaths) { - List expandedPaths = new ArrayList<>(); - - for (String path : filterPaths) { - if (path.contains("[") && path.contains("]")) { - expandedPaths.addAll(expandGroupedPath(path)); - } else { - expandedPaths.add(path); - } - } - - return expandedPaths; - } - - /** - * Expands a single grouped path into individual paths. - * Supports nested brackets like "locations[home[street],work[city]]" - * - * @param groupedPath the grouped path to expand - * @return a list of individual paths - */ - private List expandGroupedPath(String groupedPath) { - List expandedPaths = new ArrayList<>(); - - int startBracket = groupedPath.indexOf('['); - - // Find the matching closing bracket by counting bracket depth - int endBracket = findMatchingClosingBracket(groupedPath, startBracket); - if (endBracket == -1) { - expandedPaths.add(groupedPath); - return expandedPaths; - } - - String prefix = groupedPath.substring(0, startBracket); - String groupedContent = groupedPath.substring(startBracket + 1, endBracket); - - // Parse the grouped content, handling nested brackets - List subPaths = parseGroupedContent(groupedContent); - - for (String subPath : subPaths) { - if (!subPath.trim().isEmpty()) { - expandedPaths.add(prefix + "." + subPath.trim()); - } - } - - return expandedPaths; - } - - /** - * Finds the matching closing bracket for the given opening bracket position. - * - * @param text the text to search in - * @param startPos the position of the opening bracket - * @return the position of the matching closing bracket, or -1 if not found - */ - private int findMatchingClosingBracket(String text, int startPos) { - int depth = 1; - for (int i = startPos + 1; i < text.length(); i++) { - char ch = text.charAt(i); - if (ch == '[') { - depth++; - } else if (ch == ']') { - depth--; - if (depth == 0) { - return i; - } - } - } - return -1; - } - - /** - * Parses grouped content that may contain nested brackets. - * For example: "home[street,city],work[city]" -> ["home[street,city]", "work[city]"] - * - * @param content the grouped content to parse - * @return a list of sub-paths - */ - private List parseGroupedContent(String content) { - List subPaths = new ArrayList<>(); - int depth = 0; - int start = getStart(content, depth, subPaths); - - // Add the last sub-path - String lastSubPath = content.substring(start).trim(); - if (!lastSubPath.isEmpty()) { - // Recursively expand if this subPath contains brackets - if (lastSubPath.contains("[") && lastSubPath.contains("]")) { - subPaths.addAll(expandNestedSubPath(lastSubPath)); - } else { - subPaths.add(lastSubPath); - } - } - - return subPaths; - } - - /** - * Gets the start index for parsing comma-separated sub-paths in the content. - * - * @param content the content to parse - * @param depth the current bracket depth - * @param subPaths the list to store parsed sub-paths - * @return the start index for the next sub-path - */ - private int getStart(String content, int depth, List subPaths) { - int start = 0; - - for (int i = 0; i < content.length(); i++) { - char ch = content.charAt(i); - - if (ch == '[') { - depth++; - } else if (ch == ']') { - depth--; - } else if (ch == ',' && depth == 0) { - // Found a comma at the top level - this is a separator - String subPath = content.substring(start, i).trim(); - if (!subPath.isEmpty()) { - if (subPath.contains("[") && subPath.contains("]")) { - subPaths.addAll(expandNestedSubPath(subPath)); - } else { - subPaths.add(subPath); - } - } - start = i + 1; - } - } - return start; - } - - /** - * Expands a nested sub-path like "home[street,city]" into "home.street" and "home.city" - * - * @param subPath the sub-path to expand - * @return a list of expanded paths - */ - private List expandNestedSubPath(String subPath) { - List expandedPaths = new ArrayList<>(); - - int startBracket = subPath.indexOf('['); - int endBracket = findMatchingClosingBracket(subPath, startBracket); - - if (startBracket != -1 && endBracket != -1) { - String prefix = subPath.substring(0, startBracket); - String nestedContent = subPath.substring(startBracket + 1, endBracket); - - // Parse nested content that may contain its own comma-separated values - List nestedPaths = parseCommaSeparatedPaths(nestedContent); - for (String nestedPath : nestedPaths) { - String trimmed = nestedPath.trim(); - if (!trimmed.isEmpty()) { - expandedPaths.add(prefix + "." + trimmed); - } - } - } - - return expandedPaths; - } - - /** - * Parses comma-separated paths, respecting bracket nesting. - * For example: "street,city" -> ["street", "city"] - * For example: "prop1,nested[sub1,sub2]" -> ["prop1", "nested[sub1,sub2]"] - * - * @param content the content to parse - * @return a list of paths - */ - private List parseCommaSeparatedPaths(String content) { - List paths = new ArrayList<>(); - int start = 0; - - for (int i = 0; i < content.length(); i++) { - char ch = content.charAt(i); - - if (ch == ',') { - // Found a comma at the top level - this is a separator - String path = content.substring(start, i).trim(); - if (!path.isEmpty()) { - paths.add(path); - } - start = i + 1; - } - } - - // Add the last path - String lastPath = content.substring(start).trim(); - if (!lastPath.isEmpty()) { - paths.add(lastPath); - } - - return paths; - } - - private void addPathToResult(Map result, T source, String path) { - String[] parts = path.split("\\.", 2); - String currentProperty = parts[0]; - String remainingPath = parts.length > 1 ? parts[1] : null; - - Object value = getPropertyValue(source, currentProperty); - if (value == null) { - return; - } - - if (remainingPath == null) { - result.put(currentProperty, value); - } else { - extractFromNestedStructure(result, value, currentProperty, remainingPath); - } - } - - private void extractFromNestedStructure(Map result, Object value, String currentProperty, - String remainingPath) { - // Nested property using Collection - if (value instanceof Collection collection) { - getNestedStructure(result, collection, currentProperty, remainingPath).removeIf(Map::isEmpty); - - // Nested property using Array - } else if (value.getClass().isArray()) { - Object[] array = (Object[]) value; - getNestedStructure(result, Arrays.asList(array), currentProperty, remainingPath).removeIf(Map::isEmpty); - - // Nested property using Map - } else if (value instanceof Map map) { - Map nestedResult = (Map) - result.computeIfAbsent(currentProperty, k -> new LinkedHashMap<>()); - - // Split the remaining path to get the next property we're looking for - String[] remainingParts = remainingPath.split("\\.", 2); - String targetKey = remainingParts[0]; - String nextRemainingPath = remainingParts.length > 1 ? remainingParts[1] : null; - - // Only process the specific key we're looking for - if (map.containsKey(targetKey)) { - Object entryValue = map.get(targetKey); - if (nextRemainingPath == null) { - // This is the final property - set the value directly - nestedResult.put(targetKey, entryValue); - } else { - // Get or create a nested map for this key (don't overwrite existing) - Map keyNestedResult = (Map) - nestedResult.computeIfAbsent(targetKey, k -> new LinkedHashMap<>()); - - addPathToResult(keyNestedResult, entryValue, nextRemainingPath); - } - } - - // Single nested object - get or create the nested map - } else { - Map nestedResult = (Map) - result.computeIfAbsent(currentProperty, k -> new LinkedHashMap<>()); - addPathToResult(nestedResult, value, remainingPath); - } - } - - private List> getNestedStructure(Map result, Collection collection, - String currentProperty, String remainingPath) { - List> nestedResults = (List>) - result.computeIfAbsent(currentProperty, k -> new ArrayList<>()); - result.put(currentProperty, nestedResults); - - // Ensure we have enough maps in the list for all collection items - while (nestedResults.size() < collection.size()) { - nestedResults.add(new LinkedHashMap<>()); - } - - // Process each item in the collection - for (int index = 0; index < collection.size(); index++) { - Map nestedMap = nestedResults.get(index); - addPathToResult(nestedMap, ((List) collection).get(index), remainingPath); - } - - return nestedResults; - } - - private Object getPropertyValue(T source, String propertyName) { - try { - Class clazz = source.getClass(); - - // Try record component accessor method first (most efficient for records) - if (clazz.isRecord()) { - return getRecordProperty(source, propertyName, clazz); - } - - // Try getter method for regular classes - Object getterResult = tryGetterMethod(source, propertyName, clazz); - if (getterResult != null) { - return getterResult; - } - - // Fall back to direct field access - return tryDirectFieldAccess(source, propertyName, clazz); - } catch (Exception e) { - return null; - } - } - - private Object getRecordProperty(T source, String propertyName, Class clazz) throws - NoSuchMethodException, InvocationTargetException, IllegalAccessException { - Method method = clazz.getMethod(propertyName); - return method.invoke(source); - } - - private Object tryGetterMethod(T source, String propertyName, Class clazz) { - try { - String getterName = "get" + Character.toUpperCase(propertyName.charAt(0)) + - propertyName.substring(1); - Method getter = clazz.getMethod(getterName); - return getter.invoke(source); - } catch (Exception e) { - return null; - } - } - - private Object tryDirectFieldAccess(T source, String propertyName, Class clazz) { - try { - Field field = clazz.getDeclaredField(propertyName); - field.setAccessible(true); - return field.get(source); - } catch (Exception e) { - return null; - } + pathFilter.addDefaultFilterPaths(paths); } private boolean isInvalid(Map source, String property) { diff --git a/src/main/java/io/github/trackerforce/ExclusionNode.java b/src/main/java/io/github/trackerforce/ExclusionNode.java new file mode 100644 index 0000000..467cefd --- /dev/null +++ b/src/main/java/io/github/trackerforce/ExclusionNode.java @@ -0,0 +1,27 @@ +package io.github.trackerforce; + +import java.util.HashMap; +import java.util.Map; + +@SuppressWarnings("ALL") +class ExclusionNode { + + private boolean excludeSelf; // If true, this exact path is excluded + private final Map children; + + public ExclusionNode() { + children = new HashMap<>(); + } + + public boolean isExcludeSelf() { + return excludeSelf; + } + + public void setExcludeSelf(boolean excludeSelf) { + this.excludeSelf = excludeSelf; + } + + public Map getChildren() { + return children; + } +} diff --git a/src/main/java/io/github/trackerforce/PathCommon.java b/src/main/java/io/github/trackerforce/PathCommon.java new file mode 100644 index 0000000..01fa4fe --- /dev/null +++ b/src/main/java/io/github/trackerforce/PathCommon.java @@ -0,0 +1,301 @@ +package io.github.trackerforce; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +/** + * Common functionality for handling paths in the DotPathQL library. + * Provides methods to expand grouped paths, retrieve property values, + * and manage default filter paths. + */ +abstract class PathCommon { + + /** + * Default filter paths that can be used across different implementations. + * This allows for easy configuration of common paths to filter. + */ + protected final List defaultFilterPaths; + + /** + * Constructor to initialize the PathCommon with an empty list of default filter paths. + * This allows subclasses to add their own default paths as needed. + */ + protected PathCommon() { + this.defaultFilterPaths = new ArrayList<>(); + } + + /** + * Adds default filter paths to the list of paths that can be used + * when filtering objects. This allows for easy configuration of common + * paths that should always be available for filtering. + * + * @param paths the list of paths to add as default filter paths + */ + public void addDefaultFilterPaths(List paths) { + defaultFilterPaths.addAll(paths); + } + + /** + * Expands grouped paths like "parent[child1.prop,child2.prop]" into individual paths. + * + * @param filterPaths the list of paths that may contain grouped syntax + * @return a list of expanded individual paths + */ + protected List expandGroupedPaths(List filterPaths) { + List expandedPaths = new ArrayList<>(); + + for (String path : filterPaths) { + if (path.contains("[") && path.contains("]")) { + expandedPaths.addAll(expandGroupedPath(path)); + } else { + expandedPaths.add(path); + } + } + + return expandedPaths; + } + + /** + * Retrieves the value of a property from the given source object. + * This method first checks if the source is a record and uses the record component accessor method + * if available. If not, it attempts to find a getter method or directly access the field. + * If any of these methods fail, it returns null. + * + * @param source the source object from which to retrieve the property value + * @param propertyName the name of the property to retrieve + * @return the value of the property, or null if not found or an error occurs + * @param the type of the source object + */ + protected Object getPropertyValue(T source, String propertyName) { + try { + Class clazz = source.getClass(); + + // Try record component accessor method first (most efficient for records) + if (clazz.isRecord()) { + return getRecordProperty(source, propertyName, clazz); + } + + // Try getter method for regular classes + Object getterResult = tryGetterMethod(source, propertyName, clazz); + if (getterResult != null) { + return getterResult; + } + + // Fall back to direct field access + return tryDirectFieldAccess(source, propertyName, clazz); + } catch (Exception e) { + return null; + } + } + + private Object getRecordProperty(T source, String propertyName, Class clazz) throws + NoSuchMethodException, InvocationTargetException, IllegalAccessException { + Method method = clazz.getMethod(propertyName); + return method.invoke(source); + } + + private Object tryGetterMethod(T source, String propertyName, Class clazz) { + try { + String getterName = "get" + Character.toUpperCase(propertyName.charAt(0)) + + propertyName.substring(1); + Method getter = clazz.getMethod(getterName); + return getter.invoke(source); + } catch (Exception e) { + return null; + } + } + + private Object tryDirectFieldAccess(T source, String propertyName, Class clazz) { + try { + Field field = clazz.getDeclaredField(propertyName); + field.setAccessible(true); + return field.get(source); + } catch (Exception e) { + return null; + } + } + + /** + * Expands a single grouped path into individual paths. + * Supports nested brackets like "locations[home[street],work[city]]" + * + * @param groupedPath the grouped path to expand + * @return a list of individual paths + */ + private List expandGroupedPath(String groupedPath) { + List expandedPaths = new ArrayList<>(); + + int startBracket = groupedPath.indexOf('['); + + // Find the matching closing bracket by counting bracket depth + int endBracket = findMatchingClosingBracket(groupedPath, startBracket); + if (endBracket == -1) { + expandedPaths.add(groupedPath); + return expandedPaths; + } + + String prefix = groupedPath.substring(0, startBracket); + String groupedContent = groupedPath.substring(startBracket + 1, endBracket); + + // Parse the grouped content, handling nested brackets + List subPaths = parseGroupedContent(groupedContent); + + for (String subPath : subPaths) { + if (!subPath.trim().isEmpty()) { + expandedPaths.add(prefix + "." + subPath.trim()); + } + } + + return expandedPaths; + } + + /** + * Finds the matching closing bracket for the given opening bracket position. + * + * @param text the text to search in + * @param startPos the position of the opening bracket + * @return the position of the matching closing bracket, or -1 if not found + */ + private int findMatchingClosingBracket(String text, int startPos) { + int depth = 1; + for (int i = startPos + 1; i < text.length(); i++) { + char ch = text.charAt(i); + if (ch == '[') { + depth++; + } else if (ch == ']') { + depth--; + if (depth == 0) { + return i; + } + } + } + return -1; + } + + /** + * Parses grouped content that may contain nested brackets. + * For example: "home[street,city],work[city]" -> ["home[street,city]", "work[city]"] + * + * @param content the grouped content to parse + * @return a list of sub-paths + */ + private List parseGroupedContent(String content) { + List subPaths = new ArrayList<>(); + int depth = 0; + int start = getStart(content, depth, subPaths); + + // Add the last sub-path + String lastSubPath = content.substring(start).trim(); + if (!lastSubPath.isEmpty()) { + // Recursively expand if this subPath contains brackets + if (lastSubPath.contains("[") && lastSubPath.contains("]")) { + subPaths.addAll(expandNestedSubPath(lastSubPath)); + } else { + subPaths.add(lastSubPath); + } + } + + return subPaths; + } + + /** + * Gets the start index for parsing comma-separated sub-paths in the content. + * + * @param content the content to parse + * @param depth the current bracket depth + * @param subPaths the list to store parsed sub-paths + * @return the start index for the next sub-path + */ + private int getStart(String content, int depth, List subPaths) { + int start = 0; + + for (int i = 0; i < content.length(); i++) { + char ch = content.charAt(i); + + if (ch == '[') { + depth++; + } else if (ch == ']') { + depth--; + } else if (ch == ',' && depth == 0) { + // Found a comma at the top level - this is a separator + String subPath = content.substring(start, i).trim(); + if (!subPath.isEmpty()) { + if (subPath.contains("[") && subPath.contains("]")) { + subPaths.addAll(expandNestedSubPath(subPath)); + } else { + subPaths.add(subPath); + } + } + start = i + 1; + } + } + return start; + } + + /** + * Expands a nested sub-path like "home[street,city]" into "home.street" and "home.city" + * + * @param subPath the sub-path to expand + * @return a list of expanded paths + */ + private List expandNestedSubPath(String subPath) { + List expandedPaths = new ArrayList<>(); + + int startBracket = subPath.indexOf('['); + int endBracket = findMatchingClosingBracket(subPath, startBracket); + + if (startBracket != -1 && endBracket != -1) { + String prefix = subPath.substring(0, startBracket); + String nestedContent = subPath.substring(startBracket + 1, endBracket); + + // Parse nested content that may contain its own comma-separated values + List nestedPaths = parseCommaSeparatedPaths(nestedContent); + for (String nestedPath : nestedPaths) { + String trimmed = nestedPath.trim(); + if (!trimmed.isEmpty()) { + expandedPaths.add(prefix + "." + trimmed); + } + } + } + + return expandedPaths; + } + + /** + * Parses comma-separated paths, respecting bracket nesting. + * For example: "street,city" -> ["street", "city"] + * For example: "prop1,nested[sub1,sub2]" -> ["prop1", "nested[sub1,sub2]"] + * + * @param content the content to parse + * @return a list of paths + */ + private List parseCommaSeparatedPaths(String content) { + List paths = new ArrayList<>(); + int start = 0; + + for (int i = 0; i < content.length(); i++) { + char ch = content.charAt(i); + + if (ch == ',') { + // Found a comma at the top level - this is a separator + String path = content.substring(start, i).trim(); + if (!path.isEmpty()) { + paths.add(path); + } + start = i + 1; + } + } + + // Add the last path + String lastPath = content.substring(start).trim(); + if (!lastPath.isEmpty()) { + paths.add(lastPath); + } + + return paths; + } + +} diff --git a/src/main/java/io/github/trackerforce/PathExclude.java b/src/main/java/io/github/trackerforce/PathExclude.java new file mode 100644 index 0000000..acdfe5c --- /dev/null +++ b/src/main/java/io/github/trackerforce/PathExclude.java @@ -0,0 +1,179 @@ +package io.github.trackerforce; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.*; + +class PathExclude extends PathCommon { + + private enum SkipValue { + INSTANCE + } + + public Map exclude(T source, List excludePaths) { + if (source == null) { + return Collections.emptyMap(); + } + + ExclusionNode root = buildExclusionTree(expandGroupedPaths(excludePaths)); + Map result = new LinkedHashMap<>(); + buildExcluding(result, source, "", root); + return result; + } + + private ExclusionNode buildExclusionTree(List paths) { + ExclusionNode root = new ExclusionNode(); + + for (String path : paths) { + if (path == null || path.isBlank()) continue; + String[] parts = path.split("\\."); + ExclusionNode current = root; + for (int i = 0; i < parts.length; i++) { + String p = parts[i]; + current = current.getChildren().computeIfAbsent(p, k -> new ExclusionNode()); + if (i == parts.length - 1) { + current.setExcludeSelf(true); + } + } + } + + return root; + } + + private void buildExcluding(Map target, Object source, String currentPath, ExclusionNode node) { + if (source == null || isSimpleValue(source)) { + return; + } + + if (source instanceof Map map) { + excludeFromMap(target, currentPath, node, map); + return; + } + + for (String prop : getPropertyNames(source.getClass())) { + ExclusionNode childNode = node == null ? null : node.getChildren().get(prop); + if (childNode != null && childNode.isExcludeSelf() && childNode.getChildren().isEmpty()) { + continue; + } + + Object value = getPropertyValue(source, prop); + String path = currentPath.isEmpty() ? prop : currentPath + "." + prop; + Object built = buildValueExcluding(value, path, childNode); + if (built != SkipValue.INSTANCE) { + target.put(prop, built); + } + } + } + + private void excludeFromMap(Map target, String currentPath, ExclusionNode node, Map map) { + for (Map.Entry entry : map.entrySet()) { + String key = String.valueOf(entry.getKey()); + ExclusionNode childNode = node == null ? null : node.getChildren().get(key); + + if (childNode != null && childNode.isExcludeSelf() && childNode.getChildren().isEmpty()) { + continue; + } + + Object value = entry.getValue(); + String path = currentPath.isEmpty() ? key : currentPath + "." + key; + Object built = buildValueExcluding(value, path, childNode); + if (built != SkipValue.INSTANCE) { + target.put(key, built); + } + } + } + + private Object buildValueExcluding(Object value, String path, ExclusionNode node) { + if (isSimpleValue(value)) { + return value; + } + + if (value instanceof Map mapVal) { + Map nested = new LinkedHashMap<>(); + buildExcluding(nested, mapVal, path, node == null ? new ExclusionNode() : node); + return nested; + } + + if (value instanceof Collection || value.getClass().isArray()) { + return handleCollectionOrArray(value, path, node); + } + + Map nested = new LinkedHashMap<>(); + buildExcluding(nested, value, path, node == null ? new ExclusionNode() : node); + return nested; + } + + private Object handleCollectionOrArray(Object value, String path, ExclusionNode node) { + Object[] array = null; + List list; + boolean isArray = value.getClass().isArray(); + + if (isArray) { + array = (Object[]) value; + list = Arrays.asList((Object[]) value); + } else { + list = new ArrayList<>((Collection) value); + } + + boolean allSimple = list.stream().allMatch(this::isSimpleValue); + if (allSimple && (node == null || node.getChildren().isEmpty())) { + return isArray ? array : list; + } + + List items = new ArrayList<>(); + for (Object element : list) { + if (isSimpleValue(element)) { + items.add(element); + } else { + Map elementMap = new LinkedHashMap<>(); + buildExcluding(elementMap, element, path, node == null ? new ExclusionNode() : node); + items.add(elementMap); + } + } + + return items; + } + + private boolean isSimpleValue(Object value) { + return value == null || value instanceof String || value instanceof Number || value instanceof Boolean || + value instanceof Character || value instanceof Enum || value instanceof java.util.Date || + value.getClass().isPrimitive(); + } + + private List getPropertyNames(Class clazz) { + List names = new ArrayList<>(); + if (clazz.isRecord()) { + Arrays.stream(clazz.getRecordComponents()).forEach(rc -> names.add(rc.getName())); + } else { + for (Field f : clazz.getDeclaredFields()) { + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) { + continue; + } + + if (hasAccessibleGetter(clazz, f)) { + names.add(f.getName()); + } + } + } + return names; + } + + private boolean hasAccessibleGetter(Class clazz, Field field) { + String fieldName = field.getName(); + String getterName = "get" + Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1); + String booleanGetterName = "is" + Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1); + + return isMethodAccessible(clazz, field, getterName) || + isMethodAccessible(clazz, field, booleanGetterName) || + isMethodAccessible(clazz, field, fieldName); + } + + private boolean isMethodAccessible(Class clazz, Field field, String getterName) { + try { + Method getter = clazz.getMethod(getterName); + return getter.getReturnType().equals(field.getType()) && getter.getParameterCount() == 0; + } catch (NoSuchMethodException e) { + return false; + } + } +} diff --git a/src/main/java/io/github/trackerforce/PathFilter.java b/src/main/java/io/github/trackerforce/PathFilter.java new file mode 100644 index 0000000..e557d3c --- /dev/null +++ b/src/main/java/io/github/trackerforce/PathFilter.java @@ -0,0 +1,105 @@ +package io.github.trackerforce; + +import java.util.*; + +@SuppressWarnings("unchecked") +class PathFilter extends PathCommon { + + public Map filter(T source, List filterPaths) { + if (source == null) { + return Collections.emptyMap(); + } + + Map result = new LinkedHashMap<>(); + List expandedPaths = expandGroupedPaths(filterPaths); + expandedPaths.addAll(0, defaultFilterPaths); + + for (String path : expandedPaths) { + addPathToResult(result, source, path); + } + + return result; + } + + private void addPathToResult(Map result, T source, String path) { + String[] parts = path.split("\\.", 2); + String currentProperty = parts[0]; + String remainingPath = parts.length > 1 ? parts[1] : null; + + Object value = getPropertyValue(source, currentProperty); + if (value == null) { + return; + } + + if (remainingPath == null) { + result.put(currentProperty, value); + } else { + extractFromNestedStructure(result, value, currentProperty, remainingPath); + } + } + + private void extractFromNestedStructure(Map result, Object value, String currentProperty, + String remainingPath) { + // Nested property using Collection + if (value instanceof Collection collection) { + getNestedStructure(result, collection, currentProperty, remainingPath).removeIf(Map::isEmpty); + + // Nested property using Array + } else if (value.getClass().isArray()) { + Object[] array = (Object[]) value; + getNestedStructure(result, Arrays.asList(array), currentProperty, remainingPath).removeIf(Map::isEmpty); + + // Nested property using Map + } else if (value instanceof Map map) { + Map nestedResult = (Map) + result.computeIfAbsent(currentProperty, k -> new LinkedHashMap<>()); + + // Split the remaining path to get the next property we're looking for + String[] remainingParts = remainingPath.split("\\.", 2); + String targetKey = remainingParts[0]; + String nextRemainingPath = remainingParts.length > 1 ? remainingParts[1] : null; + + // Only process the specific key we're looking for + if (map.containsKey(targetKey)) { + Object entryValue = map.get(targetKey); + if (nextRemainingPath == null) { + // This is the final property - set the value directly + nestedResult.put(targetKey, entryValue); + } else { + // Get or create a nested map for this key (don't overwrite existing) + Map keyNestedResult = (Map) + nestedResult.computeIfAbsent(targetKey, k -> new LinkedHashMap<>()); + + addPathToResult(keyNestedResult, entryValue, nextRemainingPath); + } + } + + // Single nested object - get or create the nested map + } else { + Map nestedResult = (Map) + result.computeIfAbsent(currentProperty, k -> new LinkedHashMap<>()); + addPathToResult(nestedResult, value, remainingPath); + } + } + + private List> getNestedStructure(Map result, Collection collection, + String currentProperty, String remainingPath) { + List> nestedResults = (List>) + result.computeIfAbsent(currentProperty, k -> new ArrayList<>()); + result.put(currentProperty, nestedResults); + + // Ensure we have enough maps in the list for all collection items + while (nestedResults.size() < collection.size()) { + nestedResults.add(new LinkedHashMap<>()); + } + + // Process each item in the collection + for (int index = 0; index < collection.size(); index++) { + Map nestedMap = nestedResults.get(index); + addPathToResult(nestedMap, ((List) collection).get(index), remainingPath); + } + + return nestedResults; + } + +} diff --git a/src/test/java/io/github/trackerforce/ExcludeTypeClassRecordTest.java b/src/test/java/io/github/trackerforce/ExcludeTypeClassRecordTest.java new file mode 100644 index 0000000..0ca5f94 --- /dev/null +++ b/src/test/java/io/github/trackerforce/ExcludeTypeClassRecordTest.java @@ -0,0 +1,118 @@ +package io.github.trackerforce; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +class ExcludeTypeClassRecordTest { + + DotPathQL dotPathQL = new DotPathQL(); + + static Stream userDetailProvider() { + return Stream.of( + Arguments.of("Record type", io.github.trackerforce.fixture.record.UserDetail.of()), + Arguments.of("Class type", io.github.trackerforce.fixture.clazz.UserDetail.of()) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("userDetailProvider") + void shouldExcludeSimpleNestedField(String implementation, Object userDetail) { + // When + var result = dotPathQL.exclude(userDetail, List.of("orders.orderId")); + + // Then + var orders = dotPathQL.listFrom(result, "orders"); + assertNotNull(orders); + assertEquals(2, orders.size()); + assertFalse(orders.get(0).containsKey("orderId")); + assertFalse(orders.get(1).containsKey("orderId")); + assertTrue(orders.get(0).containsKey("products")); + + // roles should remain an array + var rolesObj = result.get("roles"); + assertNotNull(rolesObj); + assertTrue(rolesObj.getClass().isArray()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("userDetailProvider") + void shouldExcludeMultipleSameBranch(String implementation, Object userDetail) { + // When + var result = dotPathQL.exclude(userDetail, List.of( + "address.street", + "address.city" + )); + + // Then + var address = dotPathQL.mapFrom(result, "address"); + assertNotNull(address); + assertFalse(address.containsKey("street")); + assertFalse(address.containsKey("city")); + assertTrue(address.containsKey("zipCode")); + assertTrue(address.containsKey("country")); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("userDetailProvider") + void shouldExcludeMultipleDifferentBranches(String implementation, Object userDetail) { + // When + var result = dotPathQL.exclude(userDetail, List.of( + "address.street", + "orders.products.description", + "additionalInfo.lastLogin" + )); + + // Then + var address = dotPathQL.mapFrom(result, "address"); + assertNotNull(address); + assertFalse(address.containsKey("street")); + assertTrue(address.containsKey("city")); + + // orders products have no description + var orders = dotPathQL.listFrom(result, "orders"); + var firstOrderProducts = dotPathQL.listFrom(orders.get(0), "products"); + assertFalse(firstOrderProducts.get(0).containsKey("description")); + + // additionalInfo without lastLogin + var addInfo = dotPathQL.mapFrom(result, "additionalInfo"); + assertNotNull(addInfo); + assertFalse(addInfo.containsKey("lastLogin")); + assertEquals("English", addInfo.get("preferredLanguage")); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("userDetailProvider") + void shouldExcludeGroupedPaths(String implementation, Object userDetail) { + // When + var result = dotPathQL.exclude(userDetail, List.of("locations[home.street,work.city]")); + + // Then + var locations = dotPathQL.mapFrom(result, "locations"); + assertNotNull(locations); + + var home = dotPathQL.mapFrom(locations, "home"); + assertNotNull(home); + assertFalse(home.containsKey("street")); + assertTrue(home.containsKey("city")); + + var work = dotPathQL.mapFrom(locations, "work"); + assertFalse(work.containsKey("city")); + } + + @Test + void shouldReturnEmptyMapWhenSourceIsNull() { + // When + var result = dotPathQL.exclude(null, List.of("orders.orderId")); + + // Then + assertNotNull(result); + assertTrue(result.isEmpty()); + } +} diff --git a/src/test/java/io/github/trackerforce/ExcludeTypeClassTest.java b/src/test/java/io/github/trackerforce/ExcludeTypeClassTest.java new file mode 100644 index 0000000..f05228f --- /dev/null +++ b/src/test/java/io/github/trackerforce/ExcludeTypeClassTest.java @@ -0,0 +1,30 @@ +package io.github.trackerforce; + +import io.github.trackerforce.fixture.clazz.customer.Customer; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class ExcludeTypeClassTest { + + DotPathQL dotPathQL = new DotPathQL(); + + @Test + void shouldExcludeAndNotReturnPrivateAttributes() { + // Given + var customer = Customer.of(); + + // When + var result = dotPathQL.exclude(customer, List.of("metadata.tags")); + + // Then + var metadata = dotPathQL.mapFrom(result, "metadata"); + assertEquals(0, metadata.size()); // No fields should remain after excluding tags and private password + + var features = dotPathQL.listFrom(result, "features"); + assertEquals(2, features.size()); + assertTrue(features.get(0).containsKey("isEnabled")); // checking boolean field + } +} diff --git a/src/test/java/io/github/trackerforce/TypeClassRecordTest.java b/src/test/java/io/github/trackerforce/FilterTypeClassRecordTest.java similarity index 85% rename from src/test/java/io/github/trackerforce/TypeClassRecordTest.java rename to src/test/java/io/github/trackerforce/FilterTypeClassRecordTest.java index 0c5705a..34b0568 100644 --- a/src/test/java/io/github/trackerforce/TypeClassRecordTest.java +++ b/src/test/java/io/github/trackerforce/FilterTypeClassRecordTest.java @@ -1,5 +1,6 @@ package io.github.trackerforce; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -7,10 +8,9 @@ import java.util.List; import java.util.stream.Stream; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.*; -class TypeClassRecordTest { +class FilterTypeClassRecordTest { DotPathQL dotPathQL = new DotPathQL(); @@ -24,15 +24,12 @@ static Stream userDetailProvider() { @ParameterizedTest(name = "{0}") @MethodSource("userDetailProvider") void shouldReturnFilteredObjectAttributes(String implementation, Object userDetail) { - // Given - var filterPaths = List.of( + // When + var result = dotPathQL.filter(userDetail, List.of( "username", "address.street", "orders.products.name" - ); - - // When - var result = dotPathQL.filter(userDetail, filterPaths); + )); // Then assertEquals(3, result.size()); @@ -68,13 +65,8 @@ void shouldReturnFilteredObjectAttributes(String implementation, Object userDeta @ParameterizedTest(name = "{0}") @MethodSource("userDetailProvider") void shouldReturnFilteredObjectWithArray(String implementation, Object userDetail) { - // Given - var filterPaths = List.of( - "occupations.title" - ); - // When - var result = dotPathQL.filter(userDetail, filterPaths); + var result = dotPathQL.filter(userDetail, List.of("occupations.title")); // Then assertEquals(1, result.size()); @@ -89,14 +81,11 @@ void shouldReturnFilteredObjectWithArray(String implementation, Object userDetai @ParameterizedTest(name = "{0}") @MethodSource("userDetailProvider") void shouldReturnFilteredObjectWithMap(String implementation, Object userDetail) { - // Given - var filterPaths = List.of( + // When + var result = dotPathQL.filter(userDetail, List.of( "additionalInfo.preferredLanguage", "additionalInfo.subscriptionStatus" - ); - - // When - var result = dotPathQL.filter(userDetail, filterPaths); + )); // Then assertEquals(1, result.size()); @@ -111,14 +100,11 @@ void shouldReturnFilteredObjectWithMap(String implementation, Object userDetail) @ParameterizedTest(name = "{0}") @MethodSource("userDetailProvider") void shouldReturnFilteredObjectWithComplexMap(String implementation, Object userDetail) { - // Given - var filterPaths = List.of( + // When + var result = dotPathQL.filter(userDetail, List.of( "locations.home.street", "locations.work.city" - ); - - // When - var result = dotPathQL.filter(userDetail, filterPaths); + )); // Then assertEquals(1, result.size()); @@ -158,13 +144,8 @@ void shouldAddDefaultFilterPaths(String implementation, Object userDetail) { @ParameterizedTest(name = "{0}") @MethodSource("userDetailProvider") void shouldReturnFilteredObjectUsingGroupedPaths(String implementation, Object userDetail) { - // Given - var filterPaths = List.of( - "locations[home.street,work.city]" - ); - // When - var result = dotPathQL.filter(userDetail, filterPaths); + var result = dotPathQL.filter(userDetail, List.of("locations[home.street,work.city]")); // Then assertEquals(1, result.size()); @@ -186,13 +167,8 @@ void shouldReturnFilteredObjectUsingGroupedPaths(String implementation, Object u @ParameterizedTest(name = "{0}") @MethodSource("userDetailProvider") void shouldReturnFilteredObjectUsingNestedGroupedPaths(String implementation, Object userDetail) { - // Given - var filterPaths = List.of( - "locations[home[street,city],work[city]]" - ); - // When - var result = dotPathQL.filter(userDetail, filterPaths); + var result = dotPathQL.filter(userDetail, List.of("locations[home[street,city],work[city]]")); // Then assertEquals(1, result.size()); @@ -215,15 +191,20 @@ void shouldReturnFilteredObjectUsingNestedGroupedPaths(String implementation, Ob @ParameterizedTest(name = "{0}") @MethodSource("userDetailProvider") void shouldReturnEmptyResultInvalidGroupedPaths(String implementation, Object userDetail) { - // Given - var filterPaths = List.of( - "locations]home[" // Invalid grouped path - ); - // When - var result = dotPathQL.filter(userDetail, filterPaths); + var result = dotPathQL.filter(userDetail, List.of("locations]home[")); // Invalid grouped path // Then assertEquals(0, result.size()); } + + @Test + void shouldReturnEmptyMapWhenSourceIsNull() { + // When + var result = dotPathQL.filter(null, List.of("orders.orderId")); + + // Then + assertNotNull(result); + assertTrue(result.isEmpty()); + } } diff --git a/src/test/java/io/github/trackerforce/TypeClassTest.java b/src/test/java/io/github/trackerforce/FilterTypeClassTest.java similarity index 87% rename from src/test/java/io/github/trackerforce/TypeClassTest.java rename to src/test/java/io/github/trackerforce/FilterTypeClassTest.java index 663dbbd..7f6d4fe 100644 --- a/src/test/java/io/github/trackerforce/TypeClassTest.java +++ b/src/test/java/io/github/trackerforce/FilterTypeClassTest.java @@ -8,7 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -class TypeClassTest { +class FilterTypeClassTest { DotPathQL dotPathQL = new DotPathQL(); @@ -16,13 +16,12 @@ class TypeClassTest { void shouldReturnFilteredObjectPrivateAttributes() { // Given var customer = Customer.of(); - var filterPaths = List.of( - "metadata.password", - "metadata.tags" - ); // When - var result = dotPathQL.filter(customer, filterPaths); + var result = dotPathQL.filter(customer, List.of( + "metadata.password", + "metadata.tags" + )); // Then assertEquals(1, result.size()); @@ -44,10 +43,9 @@ void shouldReturnFilteredObjectPrivateAttributes() { void shouldReturnFilteredObjectListOfListAttributes() { // Given var customer = Customer.of(); - var filterPaths = List.of("features.metadata.tags"); // When - var result = dotPathQL.filter(customer, filterPaths); + var result = dotPathQL.filter(customer, List.of("features.metadata.tags")); // Then assertEquals(1, result.size()); diff --git a/src/test/java/io/github/trackerforce/fixture/clazz/Occupation.java b/src/test/java/io/github/trackerforce/fixture/clazz/Occupation.java index fb8ed64..f8f6e8f 100644 --- a/src/test/java/io/github/trackerforce/fixture/clazz/Occupation.java +++ b/src/test/java/io/github/trackerforce/fixture/clazz/Occupation.java @@ -14,4 +14,5 @@ public class Occupation { double salary; String department; int yearsOfExperience; + Address address; } diff --git a/src/test/java/io/github/trackerforce/fixture/clazz/UserDetail.java b/src/test/java/io/github/trackerforce/fixture/clazz/UserDetail.java index e92ba25..2b46270 100644 --- a/src/test/java/io/github/trackerforce/fixture/clazz/UserDetail.java +++ b/src/test/java/io/github/trackerforce/fixture/clazz/UserDetail.java @@ -55,8 +55,12 @@ public static UserDetail of() { ), new String[] {"USER", "ADMIN"}, new Occupation[] { - new Occupation("Software Engineer", "Develops software applications", 90000.00, "Engineering", 5), - new Occupation("Project Manager", "Manages software projects", 95000.00, "Management", 7) + new Occupation("Software Engineer", "Develops software applications", 90000.00, "Engineering", 5, + new Address("123 Tech St", "Tech City", "CA", "90001", "USA") + ), + new Occupation("Project Manager", "Manages software projects", 95000.00, "Management", 7, + new Address("456 Project Ave", "Project City", "CA", "90002", "USA") + ) }, Map.of( "preferredLanguage", "English", diff --git a/src/test/java/io/github/trackerforce/fixture/record/Occupation.java b/src/test/java/io/github/trackerforce/fixture/record/Occupation.java index cf6edc8..9e94b8d 100644 --- a/src/test/java/io/github/trackerforce/fixture/record/Occupation.java +++ b/src/test/java/io/github/trackerforce/fixture/record/Occupation.java @@ -5,6 +5,7 @@ public record Occupation( String description, double salary, String department, - int yearsOfExperience + int yearsOfExperience, + Address address ) { } diff --git a/src/test/java/io/github/trackerforce/fixture/record/UserDetail.java b/src/test/java/io/github/trackerforce/fixture/record/UserDetail.java index 132af62..a2a7f8b 100644 --- a/src/test/java/io/github/trackerforce/fixture/record/UserDetail.java +++ b/src/test/java/io/github/trackerforce/fixture/record/UserDetail.java @@ -48,8 +48,12 @@ public static UserDetail of() { ), new String[] {"USER", "ADMIN"}, new Occupation[] { - new Occupation("Software Engineer", "Develops software applications", 90000.00, "Engineering", 5), - new Occupation("Project Manager", "Manages software projects", 95000.00, "Management", 7) + new Occupation("Software Engineer", "Develops software applications", 90000.00, "Engineering", 5, + new Address("123 Tech St", "Tech City", "CA", "90001", "USA") + ), + new Occupation("Project Manager", "Manages software projects", 95000.00, "Management", 7, + new Address("456 Project Ave", "Project City", "CA", "90002", "USA") + ) }, Map.of( "preferredLanguage", "English",