diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index e44efef..29eefb7 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -7,6 +7,28 @@ on: branches: [ master ] jobs: + build-scan: + name: SonarCloud Scan + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: 'temurin' + cache: maven + + - name: Build/Test & SonarCloud Scan + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + if: env.SONAR_TOKEN != '' + run: mvn -B clean verify -Pcoverage,sonar -Dsonar.token=${{ secrets.SONAR_TOKEN }} + build-test: name: Build & Test - JDK ${{ matrix.java }} on ${{ matrix.os }} strategy: diff --git a/README.md b/README.md index 643f610..7100fa7 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ The `DotPathQL` is the core component of this project that allows you to extract - πŸ“‹ **Collection Handling**: Process Lists, Arrays, and other Collections - πŸ—ΊοΈ **Map Support**: Handle both simple and complex Map structures - πŸ“ **Record & POJO Support**: Works with Java Records and traditional classes +- πŸ“„ **JSON Output**: Convert results to pretty-formatted or compact JSON strings - πŸ”’ **Private Field Access**: Can access private fields when getters aren't available (filtering only) - πŸš€ **Performance Optimized**: Efficient reflection-based property access @@ -174,23 +175,88 @@ List reportFields = List.of( ); ``` +## JSON Output + +Convert your filtered or excluded results to JSON format using the built-in `toJson` method. This feature supports both pretty-formatted (indented) and compact (single-line) output. + +### Pretty JSON + +```java +var dotPathQL = new DotPathQL(); +var result = dotPathQL.filter(userObject, List.of( + "username", + "address.street", + "address.city", + "orders.products.name" +)); + +// Pretty formatted JSON with 2-space indentation +String prettyJson = dotPathQL.toJson(result, true); +//or +String prettyJson = dotPathQL.toJson(result, 4); // Custom indentation level +``` + +**Output:** +```json +{ + "username": "john_doe", + "address": { + "street": "123 Main St", + "city": "Springfield" + }, + "orders": [ + { + "products": [ + { + "name": "Laptop" + }, + { + "name": "Mouse" + } + ] + } + ] +} +``` + +### Compact JSON + +```java +// Compact single-line JSON +String compactJson = dotPathQL.toJson(result, false); +System.out.println(compactJson); +``` + +**Output:** +```json +{"username": "john_doe", "address": {"street": "123 Main St", "city": "Springfield"}, "orders": [{"products": [{"name": "Laptop"}, {"name": "Mouse"}]}]} +``` + ## Helper Utilities You can also easy access the map result using the `DotPathQL` utility methods: +### Quick Access Methods + ```java // Step 1 -var dotPathQL = new DotPathQL(); -var result = dotPathQL.filter(userObject, List.of( +DotPathQL dotPathQL = new DotPathQL(); +Map result = dotPathQL.filter(userObject, List.of( "address", "friendList", "games" )); // Step 2: Accessing the result -var address = dotPathQL.mapFrom(result, "address"); -var friendList = dotPathQL.listFrom(result, "friendList"); -var games = dotPathQL.arrayFrom(result, "games"); +Map address = dotPathQL.mapFrom(result, "address"); +List> friendList = dotPathQL.listFrom(result, "friendList"); +Object[] games = dotPathQL.arrayFrom(result, "games"); +``` + +### Map objects + +```java +Map userMap = dotPathQL.toMap(userObject); ``` ## Technical Requirements diff --git a/pom.xml b/pom.xml index f963d35..5f31ea4 100644 --- a/pom.xml +++ b/pom.xml @@ -51,12 +51,19 @@ 1.18.30 + 0.8.13 + 3.11.0.3922 3.14.0 3.3.1 3.5.3 3.11.2 3.2.8 0.8.0 + + + jacoco + reuseReports + java @@ -81,6 +88,61 @@ + + coverage + + + + org.jacoco + jacoco-maven-plugin + + + jacoco-initialize + + prepare-agent + + + + jacoco-site + package + + report + + + + + + + + + sonar + + dot-path-ql + https://sonarcloud.io + trackerforce + trackerforce_dot-path-ql + ${project.groupId}:${project.artifactId} + + + false + + + + + org.sonarsource.scanner.maven + sonar-maven-plugin + + + verify + + sonar + + + + + + + sign @@ -139,6 +201,16 @@ maven-compiler-plugin ${maven-compiler-plugin.version} + + org.sonarsource.scanner.maven + sonar-maven-plugin + ${sonar-maven-plugin.version} + + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + diff --git a/src/main/java/io/github/trackerforce/DotPathQL.java b/src/main/java/io/github/trackerforce/DotPathQL.java index 3b769c1..12f10ea 100644 --- a/src/main/java/io/github/trackerforce/DotPathQL.java +++ b/src/main/java/io/github/trackerforce/DotPathQL.java @@ -5,8 +5,7 @@ import java.util.Map; /** - * Utility class for filtering objects based on specified paths. - * It supports nested properties, collections, and arrays. + * API for filtering and excluding properties from objects using dot paths. * * @author petruki * @since 2025-08-02 @@ -16,6 +15,7 @@ public class DotPathQL { private final PathCommon pathFilter; private final PathCommon pathExclude; + private final PathPrinter pathPrinter; /** * Constructs a DotPathQL instance with an empty list of default filter paths. @@ -23,6 +23,7 @@ public class DotPathQL { public DotPathQL() { pathFilter = new PathFilter(); pathExclude = new PathExclude(); + pathPrinter = new PathPrinter(2); } /** @@ -54,6 +55,17 @@ public Map exclude(T source, List excludePaths) { return pathExclude.run(source, excludePaths); } + /** + * Converts the source object to a map representation. + * + * @param the type of the source object + * @param source the source object to convert + * @return a map containing all properties of the source object + */ + public Map toMap(T source) { + return exclude(source, Collections.emptyList()); + } + /** * Extracts a map from the source map based on the specified property. * @@ -120,6 +132,29 @@ public void addDefaultExcludePaths(List paths) { pathExclude.addDefaultPaths(paths); } + /** + * Converts the given sourceMap to a JSON string representation with optional formatting. + * + * @param sourceMap the source map to convert to JSON + * @param indentSize the number of spaces to use for indentation + * @return a JSON string representation of the object + */ + public String toJson(Map sourceMap, int indentSize) { + pathPrinter.setIndentSize(indentSize); + return toJson(sourceMap, true); + } + + /** + * Converts the given sourceMap to a JSON string representation. + * + * @param sourceMap the source map to convert to JSON + * @param prettier if true, formats with proper indentation; if false, compact single-line format + * @return a JSON string representation of the object + */ + public String toJson(Map sourceMap, boolean prettier) { + return pathPrinter.toJson(sourceMap, prettier); + } + private boolean isInvalid(Map source, String property) { return source == null || property == null || property.isEmpty() || !source.containsKey(property); } diff --git a/src/main/java/io/github/trackerforce/PathPrinter.java b/src/main/java/io/github/trackerforce/PathPrinter.java new file mode 100644 index 0000000..96dc0b3 --- /dev/null +++ b/src/main/java/io/github/trackerforce/PathPrinter.java @@ -0,0 +1,164 @@ +package io.github.trackerforce; + +import java.lang.reflect.Array; +import java.util.List; +import java.util.Map; +import java.util.StringJoiner; + +class PathPrinter { + + private String indent; + + private boolean prettier; + + PathPrinter(int indentSize) { + setIndentSize(indentSize); + } + + public String toJson(Object obj, boolean prettier) { + this.prettier = prettier; + return toJsonInternal(obj, 0); + } + + public void setIndentSize(int indentSize) { + indent = " ".repeat(indentSize); + } + + private String toJsonInternal(Object obj, int depth) { + if (obj == null) { + return "null"; + } + + if (obj instanceof String value) { + return "\"" + escapeString(value) + "\""; + } + + if (obj instanceof Number || obj instanceof Boolean) { + return obj.toString(); + } + + if (obj instanceof List value) { + return listToJson(value, depth); + } + + if (obj instanceof Map value) { + return mapToJson(value, depth); + } + + if (obj.getClass().isArray()) { + return arrayToJson(obj, depth); + } + + return "\"" + escapeString(obj.toString()) + "\""; + } + + private String mapToJson(Map map, int depth) { + if (map.isEmpty()) { + return "{}"; + } + + if (!prettier) { + StringJoiner joiner = new StringJoiner(", ", "{", "}"); + for (Map.Entry entry : map.entrySet()) { + String key = "\"" + escapeString(entry.getKey().toString()) + "\""; + String value = toJsonInternal(entry.getValue(), depth); + joiner.add(key + ": " + value); + } + return joiner.toString(); + } + + StringBuilder sb = new StringBuilder(); + sb.append("{"); + + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + if (!first) { + sb.append(","); + } + sb.append("\n").append(getIndent(depth + 1)); + + String key = "\"" + escapeString(entry.getKey().toString()) + "\""; + String value = toJsonInternal(entry.getValue(), depth + 1); + sb.append(key).append(": ").append(value); + + first = false; + } + + sb.append("\n").append(getIndent(depth)).append("}"); + return sb.toString(); + } + + private String listToJson(List list, int depth) { + if (list.isEmpty()) { + return "[]"; + } + + if (!prettier) { + StringJoiner joiner = new StringJoiner(", ", "[", "]"); + for (Object item : list) { + joiner.add(toJsonInternal(item, depth)); + } + return joiner.toString(); + } + + StringBuilder sb = new StringBuilder(); + sb.append("["); + + boolean first = true; + for (Object item : list) { + if (!first) { + sb.append(","); + } + sb.append("\n").append(getIndent(depth + 1)); + sb.append(toJsonInternal(item, depth + 1)); + first = false; + } + + sb.append("\n").append(getIndent(depth)).append("]"); + return sb.toString(); + } + + private String arrayToJson(Object array, int depth) { + if (Array.getLength(array) == 0) { + return "[]"; + } + + if (!prettier) { + StringJoiner joiner = new StringJoiner(", ", "[", "]"); + for (int i = 0; i < Array.getLength(array); i++) { + joiner.add(toJsonInternal(Array.get(array, i), depth)); + } + return joiner.toString(); + } + + StringBuilder sb = new StringBuilder(); + sb.append("["); + + for (int i = 0; i < Array.getLength(array); i++) { + if (i > 0) { + sb.append(","); + } + sb.append("\n").append(getIndent(depth + 1)); + sb.append(toJsonInternal(Array.get(array, i), depth + 1)); + } + + sb.append("\n").append(getIndent(depth)).append("]"); + return sb.toString(); + } + + private String getIndent(int depth) { + return indent.repeat(depth); + } + + private String escapeString(String str) { + if (str == null) { + return ""; + } + + return str.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } +} diff --git a/src/test/java/io/github/trackerforce/PrintTypeClassRecordTest.java b/src/test/java/io/github/trackerforce/PrintTypeClassRecordTest.java new file mode 100644 index 0000000..1747f0f --- /dev/null +++ b/src/test/java/io/github/trackerforce/PrintTypeClassRecordTest.java @@ -0,0 +1,89 @@ +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.HashMap; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class PrintTypeClassRecordTest { + + 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 shouldPrintTypeClassRecord(String implementation, Object userDetail) { + // Given + var result = dotPathQL.toMap(userDetail); + + // When + var json = dotPathQL.toJson(result, false); + + // Then + assertNotNull(json); + assertEquals(1, json.indexOf("\"username\": \"john_doe\"")); + assertEquals(118, json.indexOf("\"street\": \"123 Main St\"")); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("userDetailProvider") + void shouldPrintPrettyTypeClassRecord(String implementation, Object userDetail) { + // Given + var result = dotPathQL.toMap(userDetail); + + // When + var json = dotPathQL.toJson(result, true); + + // Then + assertNotNull(json); + assertEquals(2, json.indexOf(" \"username\": \"john_doe\"")); + assertEquals(130, json.indexOf(" \"street\": \"123 Main St\"")); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("userDetailProvider") + void shouldPrintPrettyIndentTypeClassRecord(String implementation, Object userDetail) { + // Given + var result = dotPathQL.toMap(userDetail); + + // When + var json = dotPathQL.toJson(result, 4); + + // Then + assertNotNull(json); + assertEquals(2, json.indexOf(" \"username\": \"john_doe\"")); + assertEquals(140, json.indexOf(" \"street\": \"123 Main St\"")); + } + + @Test + void shouldPrintEmptyJsonForNullInput() { + // When + var json = dotPathQL.toJson(new HashMap<>(), false); + + // Then + assertNotNull(json); + assertEquals("{}", json); + } + + @Test + void shouldPrintNullForNullResult() { + // When + var json = dotPathQL.toJson(null, false); + + // Then + assertNotNull(json); + assertEquals("null", json); + } +} diff --git a/src/test/java/io/github/trackerforce/UtilsTest.java b/src/test/java/io/github/trackerforce/UtilsTest.java new file mode 100644 index 0000000..b77227b --- /dev/null +++ b/src/test/java/io/github/trackerforce/UtilsTest.java @@ -0,0 +1,91 @@ +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.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class UtilsTest { + + 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 shouldReturnSelectedArrayProperties(String implementation, Object userDetail) { + // When + var result = dotPathQL.toMap(userDetail); + var roles = dotPathQL.arrayFrom(result, "roles"); + + // Then + assertNotNull(roles); + assertEquals(2, roles.length); + assertEquals("USER", roles[0]); + assertEquals("ADMIN", roles[1]); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("userDetailProvider") + void shouldReturnSelectedListProperties(String implementation, Object userDetail) { + // When + var result = dotPathQL.toMap(userDetail); + var occupations = dotPathQL.listFrom(result, "occupations"); + + // Then + assertNotNull(occupations); + assertEquals(2, occupations.size()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("userDetailProvider") + void shouldReturnSelectedMapProperties(String implementation, Object userDetail) { + // When + var result = dotPathQL.toMap(userDetail); + var locations = dotPathQL.mapFrom(result, "locations"); + + // Then + assertNotNull(locations); + assertEquals(2, locations.size()); + } + + @Test + void shouldReturnEmptyArrayWhenPropertyNotExists() { + // When + var unknown = dotPathQL.arrayFrom(null, "unknown"); + + // Then + assertNotNull(unknown); + assertEquals(0, unknown.length); + } + + @Test + void shouldReturnEmptyListWhenPropertyNotExists() { + // When + var unknown = dotPathQL.listFrom(null, "unknown"); + + // Then + assertNotNull(unknown); + assertEquals(0, unknown.size()); + } + + @Test + void shouldReturnEmptyMapWhenPropertyNotExists() { + // When + var unknown = dotPathQL.mapFrom(null, "unknown"); + + // Then + assertNotNull(unknown); + assertEquals(0, unknown.size()); + } +} diff --git a/src/test/java/io/github/trackerforce/fixture/clazz/Address.java b/src/test/java/io/github/trackerforce/fixture/clazz/Address.java index 94ab60d..8dbd1f8 100644 --- a/src/test/java/io/github/trackerforce/fixture/clazz/Address.java +++ b/src/test/java/io/github/trackerforce/fixture/clazz/Address.java @@ -5,6 +5,8 @@ import lombok.Data; import lombok.experimental.FieldDefaults; +import java.util.List; + @Data @AllArgsConstructor @FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) @@ -15,6 +17,8 @@ public class Address { String zipCode; String country; int[] coordinates; + char[] code; + List numbers; public static Address of123() { return new Address( @@ -23,7 +27,9 @@ public static Address of123() { "IL", "62701", "USA", - new int[]{40, 90} + new int[]{40, 90}, + new char[]{'A', 'B', 'C'}, + List.of(1, 2, 3, 4, 5) ); } @@ -34,7 +40,10 @@ public static Address of456() { "IL", "62701", "USA", - new int[]{39, 89}); + new int[]{39, 89}, + new char[]{'X', 'Y', 'Z'}, + List.of(6, 7, 8, 9, 10) + ); } public static Address of789() { @@ -44,6 +53,9 @@ public static Address of789() { "IL", "62701", "USA", - new int[]{39, 89}); + new int[]{39, 89}, + new char[]{'1', '2', '3'}, + List.of(11, 12, 13, 14, 15) + ); } } 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 46e9052..cf4406a 100644 --- a/src/test/java/io/github/trackerforce/fixture/clazz/Occupation.java +++ b/src/test/java/io/github/trackerforce/fixture/clazz/Occupation.java @@ -5,6 +5,8 @@ import lombok.Data; import lombok.experimental.FieldDefaults; +import java.util.List; + @Data @AllArgsConstructor @FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) @@ -23,7 +25,15 @@ public static Occupation ofSoftwareEngineer() { 90000.00, "Engineering", 5, - new Address("123 Tech St", "Tech City", "CA", "90001", "USA", new int[]{37, 122}) + new Address( + "123 Tech St", + "Tech City", + "CA", + "90001", + "USA", + new int[]{37, 122}, + new char[0], + List.of()) ); } @@ -34,7 +44,15 @@ public static Occupation ofProjectManager() { 95000.00, "Management", 7, - new Address("456 Project Ave", "Project City", "CA", "90002", "USA", new int[]{34, 118}) + new Address( + "456 Project Ave", + "Project City", + "CA", + "90002", + "USA", + new int[]{34, 118}, + new char[0], + List.of()) ); } } diff --git a/src/test/java/io/github/trackerforce/fixture/clazz/Order.java b/src/test/java/io/github/trackerforce/fixture/clazz/Order.java index 7e2d17c..262d5ec 100644 --- a/src/test/java/io/github/trackerforce/fixture/clazz/Order.java +++ b/src/test/java/io/github/trackerforce/fixture/clazz/Order.java @@ -18,7 +18,7 @@ public class Order { public static Order ofOrder123() { return new Order( List.of(Product.ofLaptop(), Product.ofSmartphone()), - new Date(), + new Date(1754766345000L), "order123" ); } @@ -26,7 +26,7 @@ public static Order ofOrder123() { public static Order ofOrder456() { return new Order( List.of(Product.ofHeadphones()), - new Date(), + new Date(1754766345000L), "order456" ); } diff --git a/src/test/java/io/github/trackerforce/fixture/record/Address.java b/src/test/java/io/github/trackerforce/fixture/record/Address.java index ae46b23..0697aa4 100644 --- a/src/test/java/io/github/trackerforce/fixture/record/Address.java +++ b/src/test/java/io/github/trackerforce/fixture/record/Address.java @@ -1,12 +1,16 @@ package io.github.trackerforce.fixture.record; +import java.util.List; + public record Address( String street, String city, String state, String zipCode, String country, - int[] coordinates + int[] coordinates, + char[] code, + List numbers ) { public static Address of123() { return new Address( @@ -15,7 +19,9 @@ public static Address of123() { "IL", "62701", "USA", - new int[]{40, 90} + new int[]{40, 90}, + new char[]{'A', 'B', 'C'}, + List.of(1, 2, 3, 4, 5) ); } @@ -26,7 +32,10 @@ public static Address of456() { "IL", "62701", "USA", - new int[]{39, 89}); + new int[]{39, 89}, + new char[]{'X', 'Y', 'Z'}, + List.of(6, 7, 8, 9, 10) + ); } public static Address of789() { @@ -36,6 +45,9 @@ public static Address of789() { "IL", "62701", "USA", - new int[]{39, 89}); + new int[]{39, 89}, + new char[]{'1', '2', '3'}, + List.of(11, 12, 13, 14, 15) + ); } } 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 b10673f..314e577 100644 --- a/src/test/java/io/github/trackerforce/fixture/record/Occupation.java +++ b/src/test/java/io/github/trackerforce/fixture/record/Occupation.java @@ -1,5 +1,7 @@ package io.github.trackerforce.fixture.record; +import java.util.List; + public record Occupation( String title, String description, @@ -15,7 +17,15 @@ public static Occupation ofSoftwareEngineer() { 90000.00, "Engineering", 5, - new Address("123 Tech St", "Tech City", "CA", "90001", "USA", new int[]{37, 122}) + new Address( + "123 Tech St", + "Tech City", + "CA", + "90001", + "USA", + new int[]{37, 122}, + new char[0], + List.of()) ); } @@ -26,7 +36,15 @@ public static Occupation ofProjectManager() { 95000.00, "Management", 7, - new Address("456 Project Ave", "Project City", "CA", "90002", "USA", new int[]{34, 118}) + new Address( + "456 Project Ave", + "Project City", + "CA", + "90002", + "USA", + new int[]{34, 118}, + new char[0], + List.of()) ); } } diff --git a/src/test/java/io/github/trackerforce/fixture/record/Order.java b/src/test/java/io/github/trackerforce/fixture/record/Order.java index 55dc523..597f7f2 100644 --- a/src/test/java/io/github/trackerforce/fixture/record/Order.java +++ b/src/test/java/io/github/trackerforce/fixture/record/Order.java @@ -11,7 +11,7 @@ public record Order( public static Order ofOrder123() { return new Order( List.of(Product.ofLaptop(), Product.ofSmartphone()), - new Date(), + new Date(1754766345000L), "order123" ); } @@ -19,7 +19,7 @@ public static Order ofOrder123() { public static Order ofOrder456() { return new Order( List.of(Product.ofHeadphones()), - new Date(), + new Date(1754766345000L), "order456" ); }