Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 136 additions & 32 deletions src/main/java/org/metricshub/jflat/JFlat.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲
* JFlat Utility
* ჻჻჻჻჻჻
* Copyright (C) 2023 Metricshb
* Copyright (C) 2023 MetricsHub
* ჻჻჻჻჻჻
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -25,7 +25,10 @@
import java.io.StringReader;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map.Entry;
import java.util.Set;
import java.util.TreeMap;
import javax.json.Json;
import javax.json.JsonArray;
Expand All @@ -46,9 +49,9 @@
*/
public class JFlat {

private TreeMap<String, String> map = new TreeMap<String, String>(String.CASE_INSENSITIVE_ORDER); // IMPORTANT: The map is case iNsEnSiTiVe!
private ArrayList<String> arrayPaths = new ArrayList<String>();
private ArrayList<Integer> arrayLengths = new ArrayList<Integer>();
private TreeMap<String, String> map = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); // IMPORTANT: The map is case iNsEnSiTiVe!
private ArrayList<String> arrayPaths = new ArrayList<>();
private ArrayList<Integer> arrayLengths = new ArrayList<>();
private Reader inputReader;
private boolean parsed = false;

Expand Down Expand Up @@ -112,9 +115,7 @@ public void parse(boolean removeNodes) throws ParseException, IOException, Illeg
throw new IOException(e.getCause());
} finally {
// In any case, close the reader
if (reader != null) {
reader.close();
}
reader.close();
}

// Parse it and build the hash map
Expand Down Expand Up @@ -166,9 +167,9 @@ private void navigateTree(JsonValue tree, String path, boolean removeNodes) {
}

// Go through each property of the object
for (String name : object.keySet()) {
for (Entry<String, JsonValue> entry : object.entrySet()) {
// The syntax of the path is object.propertyA
navigateTree(object.get(name), path + "/" + name, removeNodes);
navigateTree(entry.getValue(), path + "/" + entry.getKey(), removeNodes);
}
break;
case ARRAY:
Expand Down Expand Up @@ -340,7 +341,7 @@ public StringBuilder toCSV(String csvEntryKey, String[] csvProperties, String se
}

// Build the list of entries that will constitutes CSV records (new lines)
ArrayList<String> entries = new ArrayList<String>();
ArrayList<String> entries = new ArrayList<>();

// csvEntryKey is specified as a path (e.g. /objectA/array1/subobject)
// We will deconstruct the specified path and check whether each "subfolder" is an array or not
Expand Down Expand Up @@ -382,36 +383,139 @@ public StringBuilder toCSV(String csvEntryKey, String[] csvProperties, String se
continue;
}

// Wildcard handling:
// "*" in the path means "expand all direct children of the current object".
// This is useful for JSON structures where objects are keyed by dynamic names
// (e.g. UIDs, hashes) rather than stored in arrays.
// Example: /members/* expands to /members/key1, /members/key2, etc.
//
// Escaping: if a JSON property is literally named "*", use "\*" in the path
// to refer to it without triggering wildcard expansion.
boolean isWildcard = "*".equals(pathElement);
if ("\\*".equals(pathElement)) {
// Strip the escape backslash and treat "*" as a literal property name
pathElement = "*";
isWildcard = false;
Comment on lines +394 to +398
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "\\*" path element is now reserved to mean “literal * key”. This makes it impossible to target a JSON property whose name is literally "\\*" (backslash + asterisk), because it will always be interpreted as an escape sequence. If supporting such keys matters, consider defining an escape for the backslash itself (e.g., "\\\\*" in the path syntax) and handling it before the wildcard escape logic.

Copilot uses AI. Check for mistakes.
}

// Temporary list where we will store the new entries
ArrayList<String> newEntries = new ArrayList<String>();
ArrayList<String> newEntries = new ArrayList<>();
// Set of lower-cased paths already added, used for O(1) case-insensitive dedup
Set<String> seenLowercasePaths = new HashSet<>();

// For each existing entry, check whether entry/pathElement is an array
for (String existingEntry : entries) {
String path;
if (existingEntry.equals("/")) {
path = "/" + pathElement;
if (isWildcard) {
// Case 1: Check if existingEntry is itself an array path.
// If so, expand its indices just like the non-wildcard array logic.
int entryArrayLength = 0;
for (int i = 0; i < arrayPaths.size(); i++) {
if (existingEntry.equalsIgnoreCase(arrayPaths.get(i))) {
entryArrayLength = arrayLengths.get(i);
break;
}
}

if (entryArrayLength > 0) {
// existingEntry is an array, expand its indices
for (int i = 0; i < entryArrayLength; i++) {
newEntries.add(existingEntry + "[" + i + "]");
}
} else {
// Case 2: Check if this entry was produced by a previous array expansion
// (i.e. it ends with [n] and the parent path is in arrayPaths).
// In that case, the wildcard is redundant — just pass through.
boolean fromArrayExpansion = false;
int lastBracket = existingEntry.lastIndexOf('[');
if (lastBracket >= 0) {
String parentPath = existingEntry.substring(0, lastBracket);
for (int i = 0; i < arrayPaths.size(); i++) {
if (parentPath.equalsIgnoreCase(arrayPaths.get(i))) {
fromArrayExpansion = true;
break;
}
}
}

if (fromArrayExpansion) {
// Already expanded from an array, pass through unchanged
newEntries.add(existingEntry);
} else {
// Case 3: Expand all direct object children.
// We iterate over keys in the map that are at or after the current entry's
// prefix (e.g. "/members/") and stop once keys no longer match that prefix.
// From each matching key, we extract the immediate child name by looking for
// the next "/" or "[" delimiter, which marks a deeper level or an array index.
// Duplicates are skipped (case-insensitive) to ensure each child appears once.
String prefix = existingEntry.equals("/") ? "/" : existingEntry + "/";
for (String key : map.tailMap(prefix, true).keySet()) {
// Stop once keys are no longer under the prefix
if (!key.regionMatches(true, 0, prefix, 0, prefix.length())) {
break;
}
// Only consider keys that are strictly under the prefix
if (key.length() > prefix.length()) {
// Extract the portion after the prefix, e.g. "abc123/name" from "/members/abc123/name"
String remainder = key.substring(prefix.length());

// Find the boundary of the immediate child name:
// - "/" indicates a deeper nested property
// - "[" indicates an array index
// The child name is everything before the first such delimiter.
int slashPos = remainder.indexOf('/');
int bracketPos = remainder.indexOf('[');
String childName;
if (slashPos == -1 && bracketPos == -1) {
// No delimiter: the remainder itself is the child name (leaf key)
childName = remainder;
} else if (slashPos == -1) {
// Only "[" found: child has array children (e.g. "args[0]")
childName = remainder.substring(0, bracketPos);
} else if (bracketPos == -1) {
// Only "/" found: child has nested properties (e.g. "abc123/name")
childName = remainder.substring(0, slashPos);
} else {
// Both found: take the earlier delimiter
childName = remainder.substring(0, Math.min(slashPos, bracketPos));
}

// Add the child path if it's valid and not already seen (case-insensitive)
if (!childName.isEmpty()) {
String childPath = prefix + childName;
if (seenLowercasePaths.add(childPath.toLowerCase(Locale.ROOT))) {
newEntries.add(childPath);
}
}
}
}
}
}
} else {
path = existingEntry + "/" + pathElement;
}
String path;
if (existingEntry.equals("/")) {
path = "/" + pathElement;
} else {
path = existingEntry + "/" + pathElement;
}

// Check whether path is listed in arrayPaths
arrayLength = 0;
for (int i = 0; i < arrayPaths.size(); i++) {
if (path.equalsIgnoreCase(arrayPaths.get(i))) {
arrayLength = arrayLengths.get(i);
break;
// Check whether path is listed in arrayPaths
arrayLength = 0;
for (int i = 0; i < arrayPaths.size(); i++) {
if (path.equalsIgnoreCase(arrayPaths.get(i))) {
arrayLength = arrayLengths.get(i);
break;
}
}
}

if (arrayLength > 0) {
// So, path is an array
// Then add each entry of the array to the newEntries list
for (int i = 0; i < arrayLength; i++) {
newEntries.add(path + "[" + i + "]");
if (arrayLength > 0) {
// So, path is an array
// Then add each entry of the array to the newEntries list
for (int i = 0; i < arrayLength; i++) {
newEntries.add(path + "[" + i + "]");
}
} else {
// This is not an array, simply add path to the newEntries list
newEntries.add(path);
}
} else {
// This is not an array, simply add path to the newEntries list
newEntries.add(path);
}
}

Expand Down
43 changes: 43 additions & 0 deletions src/site/markdown/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,47 @@ public class Main {
}

}
```

# Wildcard Support for Object Keys

When a JSON structure uses dynamic object keys (e.g. a map of items keyed by UID), use the `*` wildcard in the entry key path to expand all direct children of that object into individual CSV rows.

For example, given the following JSON:

```json
{
"members": {
"abc123": { "id": 0, "name": "Drive 0", "type": { "default": "NVMe" } },
"def456": { "id": 1, "name": "Drive 1", "type": { "default": "NVMe" } }
}
}
```

Use `*` to iterate over all children of `members`:

```Java
JFlat jFlat = new JFlat(json);
jFlat.parse();

// List all entries under members
System.out.print(jFlat.toCSV("/members/*", null, ";"));
// Output:
// /members/abc123;
// /members/def456;

// Extract properties (including nested paths)
System.out.print(jFlat.toCSV("/members/*", new String[] { "id", "name", "type/default" }, ";"));
// Output:
// /members/abc123;0;Drive 0;NVMe;
// /members/def456;1;Drive 1;NVMe;
```

## Escaping the Wildcard

If you have a JSON property literally named `*`, escape it with a backslash:

```Java
// Refers to the literal property "*" under members, not a wildcard
jFlat.toCSV("/members/\\*", new String[] { "name" }, ";");
```
57 changes: 56 additions & 1 deletion src/test/java/org/metricshub/jflat/JFlatTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ void flatMap() throws IllegalStateException, ParseException, IOException {
jFlat = new JFlat(getResourceAsString("/large.json"));
jFlat.parse();
assertEquals(getResourceAsString("/large-flatMap.txt"), jFlat.getFlatTree().toString());

jFlat = new JFlat(getResourceAsString("/object-keys.json"));
jFlat.parse();
assertEquals(getResourceAsString("/object-keys-flatMap.txt"), jFlat.getFlatTree().toString());
}

@Test
Expand Down Expand Up @@ -65,13 +69,14 @@ void csv() throws IllegalStateException, ParseException, IOException {
simple.parse();
assertEquals("[0];\n[1];\n", simple.toCSV("/", null, null).toString());
assertEquals("[0]/attribute1;\n[1]/attribute1;\n", simple.toCSV("/attribute1", null, null).toString());
assertEquals("[0]/attribute1;\n[1]/attribute1;\n", simple.toCSV("/*/attribute1", null, null).toString());
assertEquals(
"[0]/arrayA[0];\n[0]/arrayA[1];\n[0]/arrayA[2];\n[1]/arrayA[0];\n[1]/arrayA[1];\n[1]/arrayA[2];\n",
simple.toCSV("/arrayA", null, null).toString()
);
assertEquals(
"[0]/arrayB[0]/id;\n[0]/arrayB[1]/id;\n[0]/arrayB[2]/id;\n[1]/arrayB[0]/id;\n[1]/arrayB[1]/id;\n[1]/arrayB[2]/id;\n",
simple.toCSV("/arrayB/id", null, null).toString()
simple.toCSV("/arrayB/*/id", null, null).toString()
);
Comment on lines 77 to 80
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existing non-wildcard array traversal path (/arrayB/id) is no longer covered by tests after switching this assertion to /arrayB/*/id. Since wildcard support is additive, keeping an assertion for /arrayB/id (in addition to the new wildcard one) would help catch regressions and confirm backward compatibility.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

assertEquals("", simple.toCSV("/nonexistent", null, null).toString());
}
Expand Down Expand Up @@ -102,6 +107,56 @@ void csvProperties() throws IllegalStateException, ParseException, IOException {
assertEquals("[0] {object} \n[1] {object} \n", simple.toCSV("/", new String[] { "." }, " ").toString());
}

@Test
void csvWildcard() throws IllegalStateException, ParseException, IOException {
JFlat nodeDrives = new JFlat(getResourceAsString("/object-keys.json"));
nodeDrives.parse();

// Wildcard to list all drive entries
assertEquals(
"/members/2415********************************;\n" +
"/members/4959********************************;\n" +
"/members/93a3********************************;\n" +
"/members/9f02********************************;\n",
nodeDrives.toCSV("/members/*", null, ";").toString()
);

// Wildcard with id and name properties
assertEquals(
"/members/2415********************************;1;Internal Drive 1;\n" +
"/members/4959********************************;0;Internal Drive 0;\n" +
"/members/93a3********************************;0;Internal Drive 0;\n" +
"/members/9f02********************************;1;Internal Drive 1;\n",
nodeDrives.toCSV("/members/*", new String[] { "id", "name" }, ";").toString()
);

// Wildcard with nested paths
assertEquals(
"/members/2415********************************;Internal Drive 1;416Y******91;NVMe;\n" +
"/members/4959********************************;Internal Drive 0;416Y******91;NVMe;\n" +
"/members/93a3********************************;Internal Drive 0;214F******S1;NVMe;\n" +
"/members/9f02********************************;Internal Drive 1;214F******S1;NVMe;\n",
nodeDrives
.toCSV("/members/*", new String[] { "name", "manufacturing/serialNumber", "type/default" }, ";")
.toString()
);
}

@Test
void csvWildcardEscape() throws IllegalStateException, ParseException, IOException {
JFlat jFlat = new JFlat(getResourceAsString("/wildcard-key.json"));
jFlat.parse();

// Wildcard "*" expands all children of "items"
assertEquals(
"/items/*;1;star;\n/items/alpha;2;alpha;\n/items/beta;3;beta;\n",
jFlat.toCSV("/items/*", new String[] { "id", "name" }, ";").toString()
);

// Escaped "\*" targets only the literal "*" property
assertEquals("/items/*;1;star;\n", jFlat.toCSV("/items/\\*", new String[] { "id", "name" }, ";").toString());
}

/**
* Reads the specified resource file and returns its content as a String
*
Expand Down
Loading
Loading