001package com.streamconverter.command.impl.json;
002
003import com.fasterxml.jackson.core.JsonFactory;
004import com.fasterxml.jackson.core.JsonGenerator;
005import com.fasterxml.jackson.core.JsonParser;
006import com.fasterxml.jackson.core.JsonToken;
007import com.streamconverter.command.AbstractStreamCommand;
008import com.streamconverter.path.IPath;
009import java.io.IOException;
010import java.io.InputStream;
011import java.io.OutputStream;
012import java.util.ArrayList;
013import java.util.List;
014
015/**
016 * JSON Filter Command Class
017 *
018 * <p>This class implements pure data extraction from JSON using TreePath expressions. Unlike
019 * JsonNavigateCommand which applies transformations, JsonFilterCommand only extracts/filters data
020 * based on specified paths without any modifications.
021 *
022 * <p>Features: - Extract specific elements using TreePath expressions - Preserve exact data types
023 * and structure of extracted elements - Streaming processing via Jackson Streaming API
024 * (JsonParser/JsonGenerator); the document is never fully loaded into memory - Support for simple
025 * path expressions including wildcards ($[*].field, $.array[*].nested.field)
026 */
027public class JsonFilterCommand extends AbstractStreamCommand {
028
029  private final IPath<List<String>> jsonPath;
030  private final JsonFactory jsonFactory;
031
032  /**
033   * Constructor for JSON filtering with typed TreePath selector.
034   *
035   * @param jsonPath the typed TreePath to extract data
036   * @throws IllegalArgumentException if jsonPath is null
037   */
038  private JsonFilterCommand(IPath<List<String>> jsonPath) {
039    this.jsonPath = jsonPath;
040    this.jsonFactory = new JsonFactory();
041  }
042
043  /**
044   * Factory method for JSON filtering with typed path selector.
045   *
046   * @param jsonPath the typed path to extract data
047   * @return a JsonFilterCommand instance
048   * @throws IllegalArgumentException if jsonPath is null
049   */
050  public static JsonFilterCommand create(IPath<List<String>> jsonPath) {
051    if (jsonPath == null) {
052      throw new IllegalArgumentException("TreePath cannot be null");
053    }
054    return new JsonFilterCommand(jsonPath);
055  }
056
057  @Override
058  public void execute(InputStream inputStream, OutputStream outputStream) throws IOException {
059    String path = jsonPath.toString();
060    List<PathSegment> segments = parsePath(path);
061
062    try (JsonParser parser = jsonFactory.createParser(inputStream);
063        JsonGenerator generator = jsonFactory.createGenerator(outputStream)) {
064      generator.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET);
065
066      if (segments.isEmpty()) {
067        // Root path "$": copy the entire document
068        copyValue(parser, generator);
069      } else {
070        extractPath(parser, generator, segments, 0);
071      }
072
073      generator.flush();
074    }
075  }
076
077  // -------------------------------------------------------------------------
078  // Path parsing
079  // -------------------------------------------------------------------------
080
081  /** A single segment in a parsed path. */
082  private static class PathSegment {
083
084    final String field; // non-null for field access
085    final boolean wildcard; // true for [*]
086    final int index; // >= 0 for numeric index, -1 otherwise
087
088    static PathSegment field(String name) {
089      return new PathSegment(name, false, -1);
090    }
091
092    static PathSegment wildcard() {
093      return new PathSegment(null, true, -1);
094    }
095
096    static PathSegment index(int i) {
097      return new PathSegment(null, false, i);
098    }
099
100    private PathSegment(String field, boolean wildcard, int index) {
101      this.field = field;
102      this.wildcard = wildcard;
103      this.index = index;
104    }
105
106    boolean isField() {
107      return field != null;
108    }
109  }
110
111  /**
112   * Parse a path string into an ordered list of {@link PathSegment}s.
113   *
114   * <p>Supported syntax:
115   *
116   * <ul>
117   *   <li>{@code $} – root (empty list)
118   *   <li>{@code $.name} – top-level field
119   *   <li>{@code $[*].name} – root-array wildcard then field
120   *   <li>{@code $.users[*].profile.department} – field, wildcard, nested fields
121   * </ul>
122   *
123   * @param path the path expression
124   * @return ordered list of path segments (empty = root)
125   */
126  private static List<PathSegment> parsePath(String path) {
127    List<PathSegment> result = new ArrayList<>();
128    if ("$".equals(path)) {
129      return result;
130    }
131
132    // Strip leading "$" then process the remaining characters
133    int start = path.startsWith("$") ? 1 : 0;
134    String rest = path.substring(start);
135
136    int i = 0;
137    while (i < rest.length()) {
138      char c = rest.charAt(i);
139      if (c == '.') {
140        i++; // skip dot separator
141      } else if (c == '[') {
142        // Array access: [*] or [N]
143        int close = rest.indexOf(']', i);
144        if (close == -1) {
145          break; // malformed – stop here
146        }
147        String inner = rest.substring(i + 1, close);
148        if ("*".equals(inner)) {
149          result.add(PathSegment.wildcard());
150        } else {
151          try {
152            result.add(PathSegment.index(Integer.parseInt(inner)));
153          } catch (NumberFormatException e) {
154            result.add(PathSegment.wildcard()); // treat unknown as wildcard
155          }
156        }
157        i = close + 1;
158      } else {
159        // Field name: read until '.', '[', or end
160        int end = i;
161        while (end < rest.length() && rest.charAt(end) != '.' && rest.charAt(end) != '[') {
162          end++;
163        }
164        String fieldName = rest.substring(i, end);
165        if (!fieldName.isEmpty()) {
166          result.add(PathSegment.field(fieldName));
167        }
168        i = end;
169      }
170    }
171    return result;
172  }
173
174  // -------------------------------------------------------------------------
175  // Streaming extraction
176  // -------------------------------------------------------------------------
177
178  /**
179   * Advance the parser to the next value and recursively navigate {@code segments[segIdx..]} to
180   * write the matching value(s) to {@code generator}.
181   *
182   * @param parser the JSON parser (not yet advanced to the target value)
183   * @param generator the JSON generator
184   * @param segments the full path segment list
185   * @param segIdx current position in {@code segments}
186   */
187  private void extractPath(
188      JsonParser parser, JsonGenerator generator, List<PathSegment> segments, int segIdx)
189      throws IOException {
190
191    if (segIdx >= segments.size()) {
192      copyValue(parser, generator);
193      return;
194    }
195
196    PathSegment seg = segments.get(segIdx);
197    JsonToken token = parser.nextToken();
198
199    if (token == null) {
200      generator.writeNull();
201      return;
202    }
203
204    if (seg.isField()) {
205      // Expect an object; find the named field
206      if (token != JsonToken.START_OBJECT) {
207        skipValue(parser, token);
208        generator.writeNull();
209        return;
210      }
211      boolean found = false;
212      JsonToken t;
213      while ((t = parser.nextToken()) != null && t != JsonToken.END_OBJECT) {
214        String name = parser.currentName();
215        if (seg.field.equals(name)) {
216          extractPath(parser, generator, segments, segIdx + 1);
217          found = true;
218        } else {
219          parser.nextToken();
220          skipValue(parser, parser.currentToken());
221        }
222      }
223      if (!found) {
224        generator.writeNull();
225      }
226    } else if (seg.wildcard) {
227      // Expect an array; iterate elements and extract from each
228      if (token != JsonToken.START_ARRAY) {
229        skipValue(parser, token);
230        generator.writeNull();
231        return;
232      }
233      generator.writeStartArray();
234      JsonToken elemToken;
235      while ((elemToken = parser.nextToken()) != null && elemToken != JsonToken.END_ARRAY) {
236        if (segIdx + 1 >= segments.size()) {
237          copyValue(parser, generator, elemToken);
238        } else {
239          extractFromToken(parser, generator, segments, segIdx + 1, elemToken);
240        }
241      }
242      generator.writeEndArray();
243    } else {
244      // Numeric index access
245      if (token != JsonToken.START_ARRAY) {
246        skipValue(parser, token);
247        generator.writeNull();
248        return;
249      }
250      int currentIdx = 0;
251      boolean found = false;
252      JsonToken arrToken;
253      while ((arrToken = parser.nextToken()) != null && arrToken != JsonToken.END_ARRAY) {
254        if (currentIdx == seg.index) {
255          extractPath(parser, generator, segments, segIdx + 1);
256          found = true;
257          JsonToken skipToken;
258          while ((skipToken = parser.nextToken()) != null && skipToken != JsonToken.END_ARRAY) {
259            skipValue(parser, skipToken);
260          }
261          break;
262        } else {
263          skipValue(parser, arrToken);
264        }
265        currentIdx++;
266      }
267      if (!found) {
268        generator.writeNull();
269      }
270    }
271  }
272
273  /**
274   * Same as {@link #extractPath} but the parser's current token has already been consumed and is
275   * supplied as {@code currentToken}. Used when iterating array elements where {@code nextToken()}
276   * was already called to detect {@code END_ARRAY}.
277   */
278  private void extractFromToken(
279      JsonParser parser,
280      JsonGenerator generator,
281      List<PathSegment> segments,
282      int segIdx,
283      JsonToken currentToken)
284      throws IOException {
285
286    if (segIdx >= segments.size()) {
287      copyValue(parser, generator, currentToken);
288      return;
289    }
290
291    PathSegment seg = segments.get(segIdx);
292
293    if (seg.isField()) {
294      if (currentToken != JsonToken.START_OBJECT) {
295        skipValue(parser, currentToken);
296        generator.writeNull();
297        return;
298      }
299      boolean found = false;
300      JsonToken t2;
301      while ((t2 = parser.nextToken()) != null && t2 != JsonToken.END_OBJECT) {
302        String name = parser.currentName();
303        if (seg.field.equals(name)) {
304          extractPath(parser, generator, segments, segIdx + 1);
305          found = true;
306        } else {
307          parser.nextToken();
308          skipValue(parser, parser.currentToken());
309        }
310      }
311      if (!found) {
312        generator.writeNull();
313      }
314    } else if (seg.wildcard) {
315      if (currentToken != JsonToken.START_ARRAY) {
316        skipValue(parser, currentToken);
317        generator.writeNull();
318        return;
319      }
320      generator.writeStartArray();
321      JsonToken elemToken2;
322      while ((elemToken2 = parser.nextToken()) != null && elemToken2 != JsonToken.END_ARRAY) {
323        if (segIdx + 1 >= segments.size()) {
324          copyValue(parser, generator, elemToken2);
325        } else {
326          extractFromToken(parser, generator, segments, segIdx + 1, elemToken2);
327        }
328      }
329      generator.writeEndArray();
330    } else {
331      if (currentToken != JsonToken.START_ARRAY) {
332        skipValue(parser, currentToken);
333        generator.writeNull();
334        return;
335      }
336      int currentIdx = 0;
337      boolean found = false;
338      JsonToken arrToken2;
339      while ((arrToken2 = parser.nextToken()) != null && arrToken2 != JsonToken.END_ARRAY) {
340        if (currentIdx == seg.index) {
341          extractPath(parser, generator, segments, segIdx + 1);
342          found = true;
343          JsonToken skipToken2;
344          while ((skipToken2 = parser.nextToken()) != null && skipToken2 != JsonToken.END_ARRAY) {
345            skipValue(parser, skipToken2);
346          }
347          break;
348        } else {
349          skipValue(parser, arrToken2);
350        }
351        currentIdx++;
352      }
353      if (!found) {
354        generator.writeNull();
355      }
356    }
357  }
358
359  // -------------------------------------------------------------------------
360  // Copy / skip helpers
361  // -------------------------------------------------------------------------
362
363  /**
364   * Copy the next complete value from {@code parser} to {@code generator}. Advances the parser by
365   * one token internally.
366   */
367  private void copyValue(JsonParser parser, JsonGenerator generator) throws IOException {
368    JsonToken token = parser.nextToken();
369    if (token == null) {
370      generator.writeNull();
371      return;
372    }
373    copyValue(parser, generator, token);
374  }
375
376  /**
377   * Copy a complete value starting at {@code token} (already read) from {@code parser} to {@code
378   * generator}.
379   */
380  private void copyValue(JsonParser parser, JsonGenerator generator, JsonToken token)
381      throws IOException {
382    switch (token) {
383      case START_OBJECT:
384        generator.writeStartObject();
385        JsonToken objToken;
386        while ((objToken = parser.nextToken()) != null && objToken != JsonToken.END_OBJECT) {
387          generator.writeFieldName(parser.currentName());
388          copyValue(parser, generator);
389        }
390        generator.writeEndObject();
391        break;
392
393      case START_ARRAY:
394        generator.writeStartArray();
395        while (true) {
396          JsonToken t = parser.nextToken();
397          if (t == JsonToken.END_ARRAY) break;
398          copyValue(parser, generator, t);
399        }
400        generator.writeEndArray();
401        break;
402
403      case VALUE_STRING:
404        generator.writeString(parser.getText());
405        break;
406
407      case VALUE_NUMBER_INT:
408        generator.writeNumber(parser.getLongValue());
409        break;
410
411      case VALUE_NUMBER_FLOAT:
412        generator.writeNumber(parser.getDoubleValue());
413        break;
414
415      case VALUE_TRUE:
416        generator.writeBoolean(true);
417        break;
418
419      case VALUE_FALSE:
420        generator.writeBoolean(false);
421        break;
422
423      default:
424        generator.writeNull();
425        break;
426    }
427  }
428
429  /**
430   * Skip over a complete value starting at {@code token} (already read). Does not write anything.
431   */
432  private void skipValue(JsonParser parser, JsonToken token) throws IOException {
433    if (token == null) {
434      return;
435    }
436    switch (token) {
437      case START_OBJECT:
438        int objDepth = 1;
439        while (objDepth > 0) {
440          JsonToken t = parser.nextToken();
441          if (t == JsonToken.START_OBJECT) objDepth++;
442          else if (t == JsonToken.END_OBJECT) objDepth--;
443        }
444        break;
445
446      case START_ARRAY:
447        int arrDepth = 1;
448        while (arrDepth > 0) {
449          JsonToken t = parser.nextToken();
450          if (t == JsonToken.START_ARRAY) arrDepth++;
451          else if (t == JsonToken.END_ARRAY) arrDepth--;
452        }
453        break;
454
455      default:
456        // Scalar values are self-contained – nothing more to skip
457        break;
458    }
459  }
460}