001package com.streamconverter.path; 002 003import java.util.ArrayList; 004import java.util.Arrays; 005import java.util.List; 006 007/** 008 * Represents a hierarchical path for tree-like data structures (JSON, XML). 009 * 010 * <p>This class handles parsing and matching of path expressions in both JSON-style ("$.user.name") 011 * and XML-style ("user/name") formats. It converts path expressions into hierarchical segments for 012 * efficient matching during data processing. 013 */ 014public class TreePath implements IPath<List<String>> { 015 016 private final List<String> segments; 017 private final String originalPath; 018 019 // Private constructor for factory methods 020 private TreePath(String originalPath, List<String> segments) { 021 this.originalPath = originalPath; 022 this.segments = segments; 023 } 024 025 /** 026 * Creates a TreePath from an XML path expression. 027 * 028 * @param xmlPath the XML path expression (e.g., "user/name") 029 * @return TreePath instance 030 * @throws IllegalArgumentException if xmlPath is null or invalid 031 */ 032 public static TreePath fromXml(String xmlPath) { 033 if (xmlPath == null || xmlPath.trim().isEmpty()) { 034 throw new IllegalArgumentException("XML path cannot be null or empty"); 035 } 036 String trimmedPath = xmlPath.trim(); 037 List<String> segments = parseXmlPathToSegments(trimmedPath); 038 return new TreePath(trimmedPath, segments); 039 } 040 041 /** 042 * Creates a TreePath from a JSON path expression. 043 * 044 * @param jsonPath the JSON path expression (e.g., "$.user.name") 045 * @return TreePath instance 046 * @throws IllegalArgumentException if jsonPath is null or invalid 047 */ 048 public static TreePath fromJson(String jsonPath) { 049 if (jsonPath == null || jsonPath.trim().isEmpty()) { 050 throw new IllegalArgumentException("JSON path cannot be null or empty"); 051 } 052 String trimmedPath = jsonPath.trim(); 053 List<String> segments = parseJsonPathToSegments(trimmedPath); 054 return new TreePath(trimmedPath, segments); 055 } 056 057 /** 058 * Checks if current path matches the target path segments 059 * 060 * @param currentPath current path segments to match against 061 * @return true if paths match exactly 062 */ 063 public boolean matches(List<String> currentPath) { 064 if (currentPath == null) { 065 return false; 066 } 067 return segments.equals(currentPath); 068 } 069 070 /** 071 * Checks if current path matches after stripping array-index syntax from this path's segments. 072 * 073 * <p>A segment like {@code "orders[*]"} or {@code "orders[0]"} is normalized to {@code "orders"} 074 * before comparison. This allows paths such as {@code "$.orders[*].product_code"} to match the 075 * streaming {@code currentPath} list {@code ["orders", "product_code"]}. 076 * 077 * @param currentPath current path segments built by the streaming traversal 078 * @return true if the normalized segments equal {@code currentPath} 079 */ 080 public boolean matchesIgnoringArraySyntax(List<String> currentPath) { 081 if (currentPath == null) { 082 return false; 083 } 084 List<String> normalized = new ArrayList<>(); 085 for (String segment : segments) { 086 // Strip leading $. prefix if present (handles complex path single-segment case) 087 String s = segment; 088 if (s.startsWith("$.")) { 089 s = s.substring(2); 090 } else if (s.startsWith("$")) { 091 s = s.substring(1); 092 } 093 // Split on dots to expand compound segments like "orders[*].product_code" 094 for (String part : s.split("\\.")) { 095 // Remove array index notation: [*], [0], [1], etc. 096 String stripped = part.replaceAll("\\[.*?\\]", ""); 097 if (!stripped.isEmpty()) { 098 normalized.add(stripped); 099 } 100 } 101 } 102 return normalized.equals(currentPath); 103 } 104 105 /** 106 * Returns the original path expression. 107 * 108 * @return the original path expression 109 */ 110 @Override 111 public String toString() { 112 return originalPath; 113 } 114 115 /** 116 * 2つのTreePathが等しいかどうかをセグメントリストで比較する。 117 * 118 * <p>注意: 元のパス文字列({@link #toString()} の値)が異なっていても、セグメントに展開した結果が同じであれば等しいとみなす。 例えば JSONスタイルの {@code 119 * "$.user.name"} と XMLスタイルの {@code "user/name"} がパース後に同じセグメントになる場合、 等しいと判定される。 120 * 121 * @param obj 比較対象のオブジェクト 122 * @return セグメントリストが等しい場合true 123 */ 124 @Override 125 public boolean equals(Object obj) { 126 if (this == obj) return true; 127 if (obj == null || getClass() != obj.getClass()) return false; 128 TreePath treePath = (TreePath) obj; 129 return segments.equals(treePath.segments); 130 } 131 132 /** 133 * セグメントリストに基づくハッシュコードを返す。 134 * 135 * @return ハッシュコード 136 */ 137 @Override 138 public int hashCode() { 139 return segments.hashCode(); 140 } 141 142 // === Internal Implementation === 143 144 private static List<String> parseJsonPathToSegments(String jsonPath) { 145 // Handle root path 146 if ("$".equals(jsonPath)) { 147 return new ArrayList<>(); 148 } 149 150 // Handle array syntax preservation for JsonFilterCommand compatibility 151 if (jsonPath.contains("[")) { 152 // For complex paths with arrays, preserve original parsing logic 153 // This ensures JsonFilterCommand continues to work 154 return parseComplexJsonPath(jsonPath); 155 } 156 157 // Simple property paths: $.property or $.nested.property 158 if (jsonPath.startsWith("$.")) { 159 String pathWithoutRoot = jsonPath.substring(2); 160 if (pathWithoutRoot.isEmpty()) { 161 return new ArrayList<>(); 162 } 163 return Arrays.asList(pathWithoutRoot.split("\\.")); 164 } 165 166 throw new IllegalArgumentException("Invalid JSON path format: " + jsonPath); 167 } 168 169 private static List<String> parseComplexJsonPath(String jsonPath) { 170 // For paths with array syntax like $[*].name or $.users[0].name 171 // Keep them as single segments to maintain compatibility 172 // The actual array handling is done in JsonFilterCommand 173 return List.of(jsonPath); 174 } 175 176 private static List<String> parseXmlPathToSegments(String xmlPath) { 177 // Remove leading and trailing slashes without regex to avoid polynomial complexity 178 String normalizedPath = removeLeadingTrailingSlashes(xmlPath); 179 if (normalizedPath.isEmpty()) { 180 return new ArrayList<>(); 181 } 182 183 // Split by single slash and filter empty segments to handle multiple consecutive slashes 184 List<String> segments = new ArrayList<>(); 185 int start = 0; 186 for (int i = 0; i <= normalizedPath.length(); i++) { 187 if (i == normalizedPath.length() || normalizedPath.charAt(i) == '/') { 188 if (i > start) { 189 segments.add(normalizedPath.substring(start, i)); 190 } 191 start = i + 1; 192 } 193 } 194 195 return segments; 196 } 197 198 /** 199 * Removes leading and trailing slashes from path without regex 200 * 201 * @param path the input path 202 * @return path with leading/trailing slashes removed 203 */ 204 private static String removeLeadingTrailingSlashes(String path) { 205 if (path == null || path.isEmpty()) { 206 return ""; 207 } 208 209 int start = 0; 210 int end = path.length(); 211 212 // Remove leading slashes 213 while (start < end && path.charAt(start) == '/') { 214 start++; 215 } 216 217 // Remove trailing slashes 218 while (end > start && path.charAt(end - 1) == '/') { 219 end--; 220 } 221 222 return path.substring(start, end); 223 } 224}