diff --git a/README.md b/README.md
index 8ead93b..6f9cc43 100644
--- a/README.md
+++ b/README.md
@@ -47,9 +47,40 @@ if( response.hasErrors() ) {
}
```
+## Streaming API
+
+For better performance when processing multiple inputs or large data, use the streaming API which
+compiles the filter once and reuses it:
+
+```java
+JqLibrary library = ImmutableJqLibrary.of();
+
+try (JqFilter filter = JqFilter.compile(library, ".[] | select(.active)")) {
+ // Process multiple inputs with the same compiled filter
+ for (String json : jsonInputs) {
+ filter.process(json, output -> System.out.println(output));
+ }
+}
+```
+
+For very large inputs that should be processed incrementally, use chunked streaming:
+
+```java
+try (JqFilter filter = JqFilter.compile(library, ".items[]")) {
+ try (JqStreamProcessor processor = filter.createStreamProcessor(output -> {
+ System.out.println(output);
+ })) {
+ // Feed chunks as they arrive (e.g., from a network stream)
+ processor.feedChunk("{\"items\": [", false);
+ processor.feedChunk("{\"id\": 1}, {\"id\": 2}", false);
+ processor.feedChunk("]}", true); // finished=true on last chunk
+ }
+}
+```
+
## Compatibility
-As of version 1.1.0, java-jq successfully executes the complete [jq](http://stedolan.github.io/jq/)
+As of version 1.1.0, java-jq successfully executes the complete [jq](http://stedolan.github.io/jq/)
test suite, including all tests in jq.test, onig.test, base64.test, and optional.test.
java-jq supports modules as well. To use modules, include the directory paths where your modules
diff --git a/src/main/java/com/arakelian/jq/JqFilter.java b/src/main/java/com/arakelian/jq/JqFilter.java
new file mode 100644
index 0000000..33dada9
--- /dev/null
+++ b/src/main/java/com/arakelian/jq/JqFilter.java
@@ -0,0 +1,576 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arakelian.jq;
+
+import static java.util.logging.Level.FINE;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.logging.Logger;
+
+import com.arakelian.jq.JqLibrary.Jv;
+import com.arakelian.jq.JqRequest.Indent;
+import com.google.common.base.Charsets;
+import com.google.common.base.Preconditions;
+import com.sun.jna.Memory;
+import com.sun.jna.Pointer;
+
+/**
+ * A compiled jq filter that can be reused for processing multiple inputs.
+ *
+ *
This class provides a streaming API for jq processing, allowing:
+ *
+ * - Compiling a filter once and reusing it across multiple inputs
+ * - Receiving output values via callback as they are produced
+ * - Processing large inputs via chunked streaming
+ * - Iterator-based access to output values
+ *
+ *
+ * Example usage:
+ * {@code
+ * JqLibrary lib = ImmutableJqLibrary.of();
+ * try (JqFilter filter = JqFilter.compile(lib, ".[] | select(.active)")) {
+ * // Callback-based processing
+ * filter.process(jsonInput, output -> System.out.println(output));
+ *
+ * // Iterator-based processing
+ * Iterator results = filter.processIterator(anotherInput);
+ * while (results.hasNext()) {
+ * System.out.println(results.next());
+ * }
+ *
+ * // Chunked input processing
+ * try (JqStreamProcessor processor = filter.createStreamProcessor(output -> ...)) {
+ * processor.feedChunk(chunk1, false);
+ * processor.feedChunk(chunk2, true); // finished=true on last chunk
+ * }
+ * }
+ * }
+ *
+ * Thread Safety: jq is not thread-safe. All operations on this filter
+ * are synchronized with a global lock to ensure thread safety.
+ */
+public class JqFilter implements AutoCloseable {
+ private static final Logger LOGGER = Logger.getLogger(JqFilter.class.getName());
+
+ /**
+ * Global lock for thread safety - jq is not thread-safe.
+ * Shared with JqRequest to ensure consistency.
+ */
+ private static final ReentrantLock SYNC = new ReentrantLock();
+
+ private final JqLibrary lib;
+ private final String filter;
+ private final Pointer jq;
+ private final int dumpFlags;
+ private volatile boolean closed = false;
+
+ private JqFilter(JqLibrary lib, String filter, Pointer jq, int dumpFlags) {
+ this.lib = lib;
+ this.filter = filter;
+ this.jq = jq;
+ this.dumpFlags = dumpFlags;
+ }
+
+ /**
+ * Compiles a jq filter expression for reuse.
+ *
+ * @param lib the jq library instance
+ * @param filter the jq filter expression to compile
+ * @return a compiled filter that can be reused
+ * @throws JqFilterException if the filter cannot be compiled
+ */
+ public static JqFilter compile(JqLibrary lib, String filter) {
+ return builder()
+ .lib(lib)
+ .filter(filter)
+ .build();
+ }
+
+ /**
+ * Creates a new builder for configuring a JqFilter.
+ *
+ * @return a new builder instance
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Builder for creating JqFilter instances with custom configuration.
+ */
+ public static class Builder {
+ private JqLibrary lib;
+ private String filter = ".";
+ private boolean pretty = false;
+ private Indent indent = Indent.NONE;
+ private boolean sortKeys = false;
+ private List modulePaths = Collections.emptyList();
+ private Map argJson = Collections.emptyMap();
+
+ /**
+ * Sets the jq library instance (required).
+ *
+ * @param lib the jq library
+ * @return this builder
+ */
+ public Builder lib(JqLibrary lib) {
+ this.lib = lib;
+ return this;
+ }
+
+ /**
+ * Sets the jq filter expression (defaults to ".").
+ *
+ * @param filter the filter expression
+ * @return this builder
+ */
+ public Builder filter(String filter) {
+ this.filter = filter;
+ return this;
+ }
+
+ /**
+ * Sets whether output should be pretty-printed (defaults to false).
+ *
+ * @param pretty true for pretty-printed output
+ * @return this builder
+ */
+ public Builder pretty(boolean pretty) {
+ this.pretty = pretty;
+ return this;
+ }
+
+ /**
+ * Sets the indentation style (defaults to NONE).
+ *
+ * @param indent the indentation style
+ * @return this builder
+ */
+ public Builder indent(Indent indent) {
+ this.indent = indent;
+ return this;
+ }
+
+ /**
+ * Sets whether to sort object keys in output (defaults to false).
+ *
+ * @param sortKeys true to sort keys
+ * @return this builder
+ */
+ public Builder sortKeys(boolean sortKeys) {
+ this.sortKeys = sortKeys;
+ return this;
+ }
+
+ /**
+ * Sets the module search paths for jq imports.
+ *
+ * @param modulePaths list of directories to search for modules
+ * @return this builder
+ */
+ public Builder modulePaths(List modulePaths) {
+ this.modulePaths = modulePaths;
+ return this;
+ }
+
+ /**
+ * Adds a single module search path.
+ *
+ * @param modulePath directory to search for modules
+ * @return this builder
+ */
+ public Builder addModulePath(File modulePath) {
+ if (this.modulePaths.isEmpty()) {
+ this.modulePaths = new ArrayList<>();
+ }
+ this.modulePaths.add(modulePath);
+ return this;
+ }
+
+ /**
+ * Sets the JSON arguments (equivalent to jq --argjson).
+ *
+ * @param argJson map of variable names to JSON values
+ * @return this builder
+ */
+ public Builder argJson(Map argJson) {
+ this.argJson = argJson;
+ return this;
+ }
+
+ /**
+ * Builds and compiles the JqFilter.
+ *
+ * @return the compiled filter
+ * @throws JqFilterException if the filter cannot be compiled
+ */
+ public JqFilter build() {
+ Preconditions.checkNotNull(lib, "lib is required");
+ Preconditions.checkNotNull(filter, "filter is required");
+
+ int flags = 0;
+ if (pretty) {
+ flags |= JqLibrary.JV_PRINT_PRETTY;
+ }
+ switch (indent) {
+ case TAB:
+ flags |= JqLibrary.JV_PRINT_TAB;
+ break;
+ case SPACE:
+ flags |= JqLibrary.JV_PRINT_SPACE1;
+ break;
+ case TWO_SPACES:
+ flags |= JqLibrary.JV_PRINT_SPACE2;
+ break;
+ case NONE:
+ default:
+ break;
+ }
+ if (sortKeys) {
+ flags |= JqLibrary.JV_PRINT_SORTED;
+ }
+
+ List modulePathStrings = new ArrayList<>();
+ for (File file : modulePaths) {
+ try {
+ modulePathStrings.add(file.getCanonicalPath());
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ SYNC.lock();
+ try {
+ return compileInternal(lib, filter, flags, modulePathStrings, argJson);
+ } finally {
+ SYNC.unlock();
+ }
+ }
+ }
+
+ private static JqFilter compileInternal(JqLibrary lib, String filter, int dumpFlags,
+ List modulePaths, Map argJson) {
+ LOGGER.log(FINE, "Initializing JQ for filter compilation");
+ final Pointer jq = lib.jq_init();
+ Preconditions.checkState(jq != null, "jq_init returned null");
+
+ // Set up module paths
+ Jv moduleDirs = lib.jv_array();
+ for (String dir : modulePaths) {
+ LOGGER.log(FINE, "Using module path: " + dir);
+ moduleDirs = lib.jv_array_append(moduleDirs, lib.jv_string(dir));
+ }
+ lib.jq_set_attr(jq, lib.jv_string("JQ_LIBRARY_PATH"), moduleDirs);
+
+ // Collect compilation errors
+ List errors = new ArrayList<>();
+ lib.jq_set_error_cb(jq, (data, jv) -> {
+ LOGGER.log(FINE, "Compilation error callback");
+ final int kind = lib.jv_get_kind(jv);
+ if (kind == JqLibrary.JV_KIND_STRING) {
+ final String error = lib.jv_string_value(jv).replaceAll("\\s++$", "");
+ errors.add(error);
+ }
+ }, new Pointer(0));
+
+ // Build arguments
+ Jv args = lib.jv_object();
+ for (String varname : argJson.keySet()) {
+ String text = argJson.get(varname);
+ Jv json = lib.jv_parse(text);
+ if (!lib.jv_is_valid(json)) {
+ lib.jv_free(args);
+ lib.jq_set_error_cb(jq, null, null);
+ lib.jq_teardown(jq);
+ throw new JqFilterException("Invalid JSON text passed to --argjson (name: " + varname + ")");
+ }
+ args = lib.jv_object_set(args, lib.jv_string(varname), json);
+ }
+
+ // Compile the filter
+ LOGGER.log(FINE, "Compiling filter: " + filter);
+ try {
+ if (!lib.jq_compile_args(jq, filter, lib.jv_copy(args))) {
+ lib.jq_set_error_cb(jq, null, null);
+ lib.jq_teardown(jq);
+ String message = errors.isEmpty() ? "Filter compilation failed: " + filter
+ : errors.get(0);
+ throw new JqFilterException(message, errors);
+ }
+ } finally {
+ lib.jv_free(args);
+ }
+
+ // Clear error callback after successful compilation
+ lib.jq_set_error_cb(jq, null, null);
+
+ LOGGER.log(FINE, "Filter compiled successfully");
+ return new JqFilter(lib, filter, jq, dumpFlags);
+ }
+
+ /**
+ * Processes JSON input and calls the callback for each output value.
+ *
+ * @param input the JSON input to process
+ * @param callback called for each output value produced by the filter
+ * @throws JqFilterException if processing fails
+ */
+ public void process(String input, JqOutputCallback callback) {
+ ensureOpen();
+ SYNC.lock();
+ try {
+ processInternal(input, callback);
+ } finally {
+ SYNC.unlock();
+ }
+ }
+
+ /**
+ * Processes JSON input and returns an iterator over output values.
+ *
+ * Note: The iterator must be fully consumed before processing more input
+ * with this filter, as jq maintains internal state during iteration.
+ *
+ * @param input the JSON input to process
+ * @return iterator over output values
+ * @throws JqFilterException if processing fails
+ */
+ public Iterator processIterator(String input) {
+ ensureOpen();
+ List results = new ArrayList<>();
+ SYNC.lock();
+ try {
+ processInternal(input, results::add);
+ } finally {
+ SYNC.unlock();
+ }
+ return results.iterator();
+ }
+
+ /**
+ * Processes JSON input and returns all output values as a list.
+ *
+ * @param input the JSON input to process
+ * @return list of all output values
+ * @throws JqFilterException if processing fails
+ */
+ public List processAll(String input) {
+ ensureOpen();
+ List results = new ArrayList<>();
+ SYNC.lock();
+ try {
+ processInternal(input, results::add);
+ } finally {
+ SYNC.unlock();
+ }
+ return results;
+ }
+
+ /**
+ * Creates a stream processor for chunked input processing.
+ *
+ * Use this when you need to process very large inputs that should be
+ * fed incrementally rather than loaded entirely into memory.
+ *
+ * Example:
+ * {@code
+ * try (JqStreamProcessor processor = filter.createStreamProcessor(output -> ...)) {
+ * processor.feedChunk(chunk1, false);
+ * processor.feedChunk(chunk2, false);
+ * processor.feedChunk(chunk3, true); // finished=true on last chunk
+ * }
+ * }
+ *
+ * @param callback called for each output value produced
+ * @return a new stream processor
+ */
+ public JqStreamProcessor createStreamProcessor(JqOutputCallback callback) {
+ ensureOpen();
+ return new JqStreamProcessor(this, callback);
+ }
+
+ private void processInternal(String input, JqOutputCallback callback) {
+ List errors = new ArrayList<>();
+
+ // Set up error callback for this processing session
+ lib.jq_set_error_cb(jq, (data, jv) -> {
+ LOGGER.log(FINE, "Processing error callback");
+ final int kind = lib.jv_get_kind(jv);
+ if (kind == JqLibrary.JV_KIND_STRING) {
+ final String error = lib.jv_string_value(jv).replaceAll("\\s++$", "");
+ errors.add(error);
+ }
+ }, new Pointer(0));
+
+ try {
+ // Create parser for this input
+ Pointer parser = lib.jv_parser_new(0);
+ try {
+ LOGGER.log(FINE, "Feeding input to parser");
+ feedParserBuffer(lib, parser, input.getBytes(Charsets.UTF_8), true);
+
+ // Process all parsed values
+ processParserOutput(parser, callback, errors);
+
+ } finally {
+ LOGGER.log(FINE, "Releasing parser");
+ lib.jv_parser_free(parser);
+ }
+ } finally {
+ lib.jq_set_error_cb(jq, null, null);
+ }
+
+ if (!errors.isEmpty()) {
+ throw new JqFilterException(errors.get(0), errors);
+ }
+ }
+
+ /**
+ * Feeds bytes to a jq parser buffer, handling the Memory allocation.
+ * Shared by JqFilter and JqStreamProcessor.
+ */
+ static void feedParserBuffer(JqLibrary lib, Pointer parser, byte[] bytes, boolean finished) {
+ if (bytes.length == 0) {
+ lib.jv_parser_set_buf(parser, null, 0, finished);
+ } else {
+ Memory memory = new Memory(bytes.length);
+ memory.write(0, bytes, 0, bytes.length);
+ lib.jv_parser_set_buf(parser, memory, bytes.length, finished);
+ }
+ }
+
+ /**
+ * Internal method to process output from a parser.
+ * Used by both process() and JqStreamProcessor.
+ */
+ void processParserOutput(Pointer parser, JqOutputCallback callback, List errors) {
+ for (;;) {
+ LOGGER.log(FINE, "Getting next parsed value");
+ Jv parsed = lib.jv_parser_next(parser);
+
+ if (!lib.jv_is_valid(parsed)) {
+ // Check if this is an error or just end of input
+ String message = getInvalidMessage(parsed);
+ if (message != null) {
+ errors.add(message);
+ }
+ break;
+ }
+
+ // Process through filter
+ LOGGER.log(FINE, "Starting jq processing");
+ lib.jq_start(jq, parsed);
+
+ for (;;) {
+ Jv next = lib.jq_next(jq);
+ if (!lib.jv_is_valid(next)) {
+ String message = getInvalidMessage(next);
+ if (message != null) {
+ errors.add(message);
+ }
+ break;
+ }
+
+ LOGGER.log(FINE, "Dumping output value");
+ String output = lib.jv_dump_string(next, dumpFlags);
+ callback.onOutput(output);
+ }
+ }
+ }
+
+ private String getInvalidMessage(Jv value) {
+ Jv copy = lib.jv_copy(value);
+ if (lib.jv_invalid_has_msg(copy)) {
+ // jv_invalid_has_msg consumed copy, jv_invalid_get_msg will consume value
+ Jv message = lib.jv_invalid_get_msg(value);
+ String msg = lib.jv_string_value(message);
+ lib.jv_free(message);
+ return msg;
+ } else {
+ // jv_invalid_has_msg consumed copy, we need to free value
+ lib.jv_free(value);
+ return null;
+ }
+ }
+
+ /**
+ * Returns the jq library instance.
+ */
+ JqLibrary getLib() {
+ return lib;
+ }
+
+ /**
+ * Returns the compiled jq state pointer.
+ */
+ Pointer getJq() {
+ return jq;
+ }
+
+ /**
+ * Returns the dump flags for serialization.
+ */
+ int getDumpFlags() {
+ return dumpFlags;
+ }
+
+ /**
+ * Returns the filter expression.
+ *
+ * @return the filter string
+ */
+ public String getFilter() {
+ return filter;
+ }
+
+ private void ensureOpen() {
+ if (closed) {
+ throw new IllegalStateException("JqFilter has been closed");
+ }
+ }
+
+ /**
+ * Releases the compiled filter resources.
+ *
+ * After closing, this filter cannot be used for processing.
+ */
+ @Override
+ public void close() {
+ if (!closed) {
+ SYNC.lock();
+ try {
+ if (!closed) {
+ LOGGER.log(FINE, "Releasing JQ state");
+ lib.jq_teardown(jq);
+ closed = true;
+ LOGGER.log(FINE, "JQ state released");
+ }
+ } finally {
+ SYNC.unlock();
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/arakelian/jq/JqFilterException.java b/src/main/java/com/arakelian/jq/JqFilterException.java
new file mode 100644
index 0000000..17917e1
--- /dev/null
+++ b/src/main/java/com/arakelian/jq/JqFilterException.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arakelian.jq;
+
+import java.util.List;
+
+import com.google.common.collect.ImmutableList;
+
+/**
+ * Exception thrown when a jq filter compilation or execution fails.
+ *
+ * This exception is used by the streaming API ({@link JqFilter}) to report
+ * errors during filter compilation or JSON processing.
+ */
+public class JqFilterException extends RuntimeException {
+ private static final long serialVersionUID = 1L;
+
+ private final List errors;
+
+ /**
+ * Creates a new JqFilterException with a single error message.
+ *
+ * @param message the error message
+ */
+ public JqFilterException(String message) {
+ super(message);
+ this.errors = ImmutableList.of(message);
+ }
+
+ /**
+ * Creates a new JqFilterException with multiple error messages.
+ *
+ * @param message the primary error message
+ * @param errors the list of all error messages
+ */
+ public JqFilterException(String message, List errors) {
+ super(message);
+ this.errors = ImmutableList.copyOf(errors);
+ }
+
+ /**
+ * Creates a new JqFilterException with an underlying cause.
+ *
+ * @param message the error message
+ * @param cause the underlying cause
+ */
+ public JqFilterException(String message, Throwable cause) {
+ super(message, cause);
+ this.errors = ImmutableList.of(message);
+ }
+
+ /**
+ * Returns all error messages associated with this exception.
+ *
+ * @return immutable list of error messages
+ */
+ public List getErrors() {
+ return errors;
+ }
+}
diff --git a/src/main/java/com/arakelian/jq/JqOutputCallback.java b/src/main/java/com/arakelian/jq/JqOutputCallback.java
new file mode 100644
index 0000000..50f5d2f
--- /dev/null
+++ b/src/main/java/com/arakelian/jq/JqOutputCallback.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arakelian.jq;
+
+/**
+ * Functional interface for receiving streaming jq output values.
+ *
+ * Each output value from the jq filter is passed to this callback as it is produced,
+ * allowing for immediate processing without buffering all results in memory.
+ *
+ * Example usage:
+ * {@code
+ * try (JqFilter filter = JqFilter.compile(library, ".[] | select(.active)")) {
+ * filter.process(jsonInput, output -> {
+ * System.out.println(output);
+ * });
+ * }
+ * }
+ */
+@FunctionalInterface
+public interface JqOutputCallback {
+ /**
+ * Called for each output value produced by the jq filter.
+ *
+ * @param output the serialized JSON output value
+ */
+ void onOutput(String output);
+}
diff --git a/src/main/java/com/arakelian/jq/JqRequest.java b/src/main/java/com/arakelian/jq/JqRequest.java
index 50b2b97..9657875 100644
--- a/src/main/java/com/arakelian/jq/JqRequest.java
+++ b/src/main/java/com/arakelian/jq/JqRequest.java
@@ -17,24 +17,13 @@
package com.arakelian.jq;
-import static java.util.logging.Level.FINE;
-
import java.io.File;
-import java.io.IOException;
-import java.io.UncheckedIOException;
import java.util.List;
import java.util.Map;
-import java.util.concurrent.locks.ReentrantLock;
-import java.util.logging.Logger;
import org.immutables.value.Value;
-import com.arakelian.jq.JqLibrary.Jv;
-import com.google.common.base.Charsets;
-import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
-import com.sun.jna.Memory;
-import com.sun.jna.Pointer;
@Value.Immutable
public abstract class JqRequest {
@@ -45,19 +34,25 @@ public enum Indent {
TWO_SPACES;
}
- private static final Logger LOGGER = Logger.getLogger(JqRequest.class.getName());
-
- /**
- * JQ is not thread-safe - https://github.com/stedolan/jq/issues/120
- */
- private static final ReentrantLock SYNC = new ReentrantLock();
-
public final JqResponse execute() {
- SYNC.lock();
- try {
- return jq();
- } finally {
- SYNC.unlock();
+ try (JqFilter filter = JqFilter.builder()
+ .lib(getLib())
+ .filter(getFilter())
+ .pretty(isPretty())
+ .indent(getIndent())
+ .sortKeys(isSortKeys())
+ .modulePaths(getModulePaths())
+ .argJson(getArgJson())
+ .build()) {
+
+ List outputs = filter.processAll(getInput());
+ return ImmutableJqResponse.builder()
+ .output(String.join(getStreamSeparator(), outputs))
+ .build();
+ } catch (JqFilterException e) {
+ return ImmutableJqResponse.builder()
+ .addAllErrors(e.getErrors())
+ .build();
}
}
@@ -66,35 +61,6 @@ public Map getArgJson() {
return ImmutableMap.of();
}
- @Value.Derived
- @Value.Auxiliary
- public int getDumpFlags() {
- int flags = 0;
-
- if (isPretty()) {
- flags |= JqLibrary.JV_PRINT_PRETTY;
- }
- switch (getIndent()) {
- case TAB:
- flags = JqLibrary.JV_PRINT_TAB;
- break;
- case SPACE:
- flags = JqLibrary.JV_PRINT_SPACE1;
- break;
- case TWO_SPACES:
- flags = JqLibrary.JV_PRINT_SPACE2;
- break;
- case NONE:
- default:
- break;
- }
-
- if (isSortKeys()) {
- flags |= JqLibrary.JV_PRINT_SORTED;
- }
- return flags;
- }
-
@Value.Default
public String getFilter() {
return ".";
@@ -125,183 +91,4 @@ public boolean isPretty() {
public boolean isSortKeys() {
return false;
}
-
- /**
- * Adds any messages produced by jq native code it to the error store, with the provided prefix.
- *
- * @param value
- * value reference
- */
- private String getInvalidMessage(final Jv value) {
- final Jv copy = getLib().jv_copy(value);
- if (getLib().jv_invalid_has_msg(copy)) {
- final Jv message = getLib().jv_invalid_get_msg(value);
- return getLib().jv_string_value(message);
- } else {
- getLib().jv_free(value);
- return null;
- }
- }
-
- private boolean isValid(final ImmutableJqResponse.Builder response, final Jv value) {
- if (getLib().jv_is_valid(value)) {
- return true;
- }
-
- // success finishes will return "invalid" value without a message
- final String message = getInvalidMessage(value);
- if (message != null) {
- response.addError(message);
- }
- return false;
- }
-
- private JqResponse jq() {
- LOGGER.log(FINE, "Initializing JQ");
- final JqLibrary lib = getLib();
- final Pointer jq = lib.jq_init();
- Preconditions.checkState(jq != null, "jq must be non-null");
-
- Jv moduleDirs = lib.jv_array();
- for (final File file : getModulePaths()) {
- try {
- final String dir = file.getCanonicalPath();
- LOGGER.log(FINE, "Using module path: " + dir);
- moduleDirs = lib.jv_array_append(moduleDirs, lib.jv_string(dir));
- } catch (final IOException e) {
- throw new UncheckedIOException(e);
- }
- }
- lib.jq_set_attr(jq, lib.jv_string("JQ_LIBRARY_PATH"), moduleDirs);
-
- try {
- final JqResponse response = parse(jq);
- LOGGER.log(FINE, "Response ready");
- return response;
- } finally {
- LOGGER.log(FINE, "Releasing JQ");
- lib.jq_teardown(jq);
- LOGGER.log(FINE, "JQ released successfully");
- }
- }
-
- private JqResponse parse(final Pointer jq) {
- final ImmutableJqResponse.Builder response = ImmutableJqResponse.builder();
-
- LOGGER.log(FINE, "Configuring callback");
- final JqLibrary lib = getLib();
- lib.jq_set_error_cb(jq, (data, jv) -> {
- LOGGER.log(FINE, "Error callback");
- final int kind = lib.jv_get_kind(jv);
- if (kind == JqLibrary.JV_KIND_STRING) {
- final String error = lib.jv_string_value(jv).replaceAll("\\s++$", "");
- response.addError(error);
- }
- }, new Pointer(0));
-
- // for JQ 1.5, arguments is an array; this changes with JQ 1.6+
- Jv args = lib.jv_object();
-
- final Map argJson = getArgJson();
- for (final String varname : argJson.keySet()) {
- final String text = argJson.get(varname);
-
- final Jv json = lib.jv_parse(text);
- if (!lib.jv_is_valid(json)) {
- response.addError("Invalid JSON text passed to --argjson (name: " + varname + ")");
- return response.build();
- }
-
- args = lib.jv_object_set(args, lib.jv_string(varname), json);
- }
-
- try {
- // compile JQ program
- LOGGER.log(FINE, "Compiling filter");
- final String filter = getFilter();
- if (!lib.jq_compile_args(jq, filter, lib.jv_copy(args))) {
- // compile errors are captured by callback
- LOGGER.log(FINE, "Compilation failed");
- return response.build();
- }
-
- // create JQ parser
- LOGGER.log(FINE, "Creating parse");
- final int parserFlags = 0;
- final Pointer parser = lib.jv_parser_new(parserFlags);
- try {
- parse(jq, parser, getInput(), response);
- return response.build();
- } finally {
- LOGGER.log(FINE, "Releasing parser");
- lib.jv_parser_free(parser);
- }
- } finally {
- LOGGER.log(FINE, "Releasing callback");
- lib.jq_set_error_cb(jq, null, null);
- }
- }
-
- /**
- * Add the contents of a native memory array as text to the next chunk of input of the jq
- * program.
- *
- * @param jq
- * JQ instance
- * @param parser
- * JQ parser
- * @param text
- * input JSON
- * @param response
- * response that we are building
- */
- private void parse(
- final Pointer jq,
- final Pointer parser,
- final String text,
- final ImmutableJqResponse.Builder response) {
- final byte[] input = text.getBytes(Charsets.UTF_8);
- final Memory memory = new Memory(input.length);
- memory.write(0, input, 0, input.length);
-
- // give text to JQ parser
- LOGGER.log(FINE, "Sending text to parser");
- getLib().jv_parser_set_buf(parser, memory, input.length, true);
-
- final int flags = getDumpFlags();
- final StringBuilder buf = new StringBuilder();
- for (;;) {
- // iterate until JQ consumes all inputs
- LOGGER.log(FINE, "Parsing text");
- final Jv parsed = getLib().jv_parser_next(parser);
- if (!isValid(response, parsed)) {
- break;
- }
-
- // iterate until we consume all JQ streams
- // see: https://stedolan.github.io/jq/tutorial/
- LOGGER.log(FINE, "Consuming JQ response");
- getLib().jq_start(jq, parsed);
- for (;;) {
- final Jv next = getLib().jq_next(jq);
- if (!isValid(response, next)) {
- break;
- }
-
- LOGGER.log(FINE, "Dumping response");
- final String out = getLib().jv_dump_string(next, flags);
- if (buf.length() != 0) {
- buf.append(getStreamSeparator());
- }
- buf.append(out);
- }
- }
-
- // tell parser we are finished
- LOGGER.log(FINE, "Finishing with parser");
-
- // finalize output
- final String output = buf.toString();
- response.output(output);
- }
}
diff --git a/src/main/java/com/arakelian/jq/JqStreamProcessor.java b/src/main/java/com/arakelian/jq/JqStreamProcessor.java
new file mode 100644
index 0000000..0134250
--- /dev/null
+++ b/src/main/java/com/arakelian/jq/JqStreamProcessor.java
@@ -0,0 +1,220 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arakelian.jq;
+
+import static java.util.logging.Level.FINE;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.logging.Logger;
+
+import com.google.common.base.Charsets;
+import com.sun.jna.Pointer;
+
+/**
+ * A stream processor for feeding chunked JSON input to a jq filter.
+ *
+ * This class allows processing very large JSON inputs incrementally,
+ * without loading the entire input into memory at once. Input chunks are
+ * fed to the parser, and output values are emitted via callback as they
+ * become available.
+ *
+ * Example usage:
+ * {@code
+ * try (JqFilter filter = JqFilter.compile(lib, ".[] | select(.active)")) {
+ * try (JqStreamProcessor processor = filter.createStreamProcessor(output -> {
+ * System.out.println(output);
+ * })) {
+ * // Feed chunks of JSON data
+ * processor.feedChunk("{\"items\": [", false);
+ * processor.feedChunk("{\"name\": \"a\", \"active\": true},", false);
+ * processor.feedChunk("{\"name\": \"b\", \"active\": false}", false);
+ * processor.feedChunk("]}", true); // finished=true on last chunk
+ * }
+ * }
+ * }
+ *
+ * Thread Safety: Operations on this processor are synchronized
+ * with the global jq lock to ensure thread safety.
+ *
+ * Important: You must call {@link #feedChunk(String, boolean)}
+ * with {@code finished=true} at least once to signal end of input, or call
+ * {@link #finish()} before closing.
+ */
+public class JqStreamProcessor implements AutoCloseable {
+ private static final Logger LOGGER = Logger.getLogger(JqStreamProcessor.class.getName());
+
+ /**
+ * Global lock for thread safety - shared with JqFilter and JqRequest.
+ */
+ private static final ReentrantLock SYNC = new ReentrantLock();
+
+ private final JqFilter filter;
+ private final JqOutputCallback callback;
+ private final JqLibrary lib;
+ private final Pointer parser;
+ private final List errors;
+
+ private volatile boolean closed = false;
+ private volatile boolean finished = false;
+
+ /**
+ * Creates a new stream processor.
+ *
+ * @param filter the compiled filter to use
+ * @param callback called for each output value
+ */
+ JqStreamProcessor(JqFilter filter, JqOutputCallback callback) {
+ this.filter = filter;
+ this.callback = callback;
+ this.lib = filter.getLib();
+ this.errors = new ArrayList<>();
+
+ SYNC.lock();
+ try {
+ LOGGER.log(FINE, "Creating parser for stream processing");
+ this.parser = lib.jv_parser_new(0);
+
+ // Set up error callback
+ lib.jq_set_error_cb(filter.getJq(), (data, jv) -> {
+ LOGGER.log(FINE, "Stream processing error callback");
+ final int kind = lib.jv_get_kind(jv);
+ if (kind == JqLibrary.JV_KIND_STRING) {
+ final String error = lib.jv_string_value(jv).replaceAll("\\s++$", "");
+ errors.add(error);
+ }
+ }, new Pointer(0));
+ } finally {
+ SYNC.unlock();
+ }
+ }
+
+ /**
+ * Feeds a chunk of JSON input to the processor.
+ *
+ * Chunks can be any portion of valid JSON - they don't need to be
+ * complete JSON values. The parser will buffer incomplete values until
+ * more data arrives.
+ *
+ * When feeding the last chunk, set {@code finished} to {@code true}
+ * to signal end of input. This allows the parser to finalize any
+ * buffered partial values.
+ *
+ * @param chunk the chunk of JSON text to process
+ * @param finished true if this is the last chunk
+ * @throws JqFilterException if a processing error occurs
+ * @throws IllegalStateException if the processor is closed or already finished
+ */
+ public void feedChunk(String chunk, boolean finished) {
+ feedChunk(chunk.getBytes(Charsets.UTF_8), finished);
+ }
+
+ /**
+ * Feeds a chunk of JSON input as raw bytes.
+ *
+ * This method is useful when working with byte streams or when
+ * the input encoding is already known to be UTF-8.
+ *
+ * @param bytes the chunk of UTF-8 encoded JSON
+ * @param finished true if this is the last chunk
+ * @throws JqFilterException if a processing error occurs
+ * @throws IllegalStateException if the processor is closed or already finished
+ */
+ public void feedChunk(byte[] bytes, boolean finished) {
+ ensureOpen();
+ if (this.finished) {
+ throw new IllegalStateException("Stream processing already finished");
+ }
+
+ SYNC.lock();
+ try {
+ LOGGER.log(FINE, "Feeding chunk to parser, finished=" + finished);
+ JqFilter.feedParserBuffer(lib, parser, bytes, finished);
+
+ // Process any complete values that are now available
+ filter.processParserOutput(parser, callback, errors);
+
+ if (finished) {
+ this.finished = true;
+ }
+ } finally {
+ SYNC.unlock();
+ }
+
+ // Throw any accumulated errors
+ if (!errors.isEmpty()) {
+ throw new JqFilterException(errors.get(0), errors);
+ }
+ }
+
+ /**
+ * Signals that all input has been provided.
+ *
+ * This is equivalent to calling {@code feedChunk("", true)} and is
+ * useful when you've already fed all chunks but haven't signaled
+ * end of input.
+ *
+ * @throws JqFilterException if a processing error occurs
+ */
+ public void finish() {
+ if (!finished) {
+ feedChunk("", true);
+ }
+ }
+
+ /**
+ * Returns whether the processor has finished receiving input.
+ *
+ * @return true if finish() was called or feedChunk was called with finished=true
+ */
+ public boolean isFinished() {
+ return finished;
+ }
+
+ private void ensureOpen() {
+ if (closed) {
+ throw new IllegalStateException("JqStreamProcessor has been closed");
+ }
+ }
+
+ /**
+ * Releases the stream processor resources.
+ *
+ * If input processing wasn't finished, this will attempt to finish it first.
+ */
+ @Override
+ public void close() {
+ if (!closed) {
+ SYNC.lock();
+ try {
+ if (!closed) {
+ // Clear error callback
+ lib.jq_set_error_cb(filter.getJq(), null, null);
+
+ LOGGER.log(FINE, "Releasing stream parser");
+ lib.jv_parser_free(parser);
+ closed = true;
+ LOGGER.log(FINE, "Stream parser released");
+ }
+ } finally {
+ SYNC.unlock();
+ }
+ }
+ }
+}
diff --git a/src/test/java/com/arakelian/jq/JqFilterTest.java b/src/test/java/com/arakelian/jq/JqFilterTest.java
new file mode 100644
index 0000000..06aea4b
--- /dev/null
+++ b/src/test/java/com/arakelian/jq/JqFilterTest.java
@@ -0,0 +1,449 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.arakelian.jq;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import com.arakelian.jq.JqRequest.Indent;
+
+/**
+ * Tests for the streaming API (JqFilter, JqStreamProcessor).
+ */
+public class JqFilterTest {
+ private static JqLibrary library;
+ private static File modulePath;
+
+ @BeforeAll
+ public static void setup() {
+ library = ImmutableJqLibrary.of();
+
+ // Find module path for tests that need imports
+ URL a_jq = JqFilterTest.class.getClassLoader().getResource("modules/a.jq");
+ if (a_jq != null) {
+ modulePath = new File(a_jq.getFile()).getParentFile();
+ }
+ }
+
+ @Test
+ public void testSimpleFilter() {
+ try (JqFilter filter = JqFilter.compile(library, ".foo")) {
+ List results = new ArrayList<>();
+ filter.process("{\"foo\": \"bar\"}", results::add);
+
+ assertEquals(1, results.size());
+ assertEquals("\"bar\"", results.get(0));
+ }
+ }
+
+ @Test
+ public void testIdentityFilter() {
+ try (JqFilter filter = JqFilter.compile(library, ".")) {
+ List results = filter.processAll("{\"a\": 1}");
+
+ assertEquals(1, results.size());
+ assertEquals("{\"a\":1}", results.get(0));
+ }
+ }
+
+ @Test
+ public void testArrayIteration() {
+ try (JqFilter filter = JqFilter.compile(library, ".[]")) {
+ List results = new ArrayList<>();
+ filter.process("[1, 2, 3]", results::add);
+
+ assertEquals(3, results.size());
+ assertEquals("1", results.get(0));
+ assertEquals("2", results.get(1));
+ assertEquals("3", results.get(2));
+ }
+ }
+
+ @Test
+ public void testSelectFilter() {
+ try (JqFilter filter = JqFilter.compile(library, ".[] | select(.active)")) {
+ List results = filter.processAll(
+ "[{\"name\": \"a\", \"active\": true}, {\"name\": \"b\", \"active\": false}, {\"name\": \"c\", \"active\": true}]");
+
+ assertEquals(2, results.size());
+ assertTrue(results.get(0).contains("\"name\":\"a\""));
+ assertTrue(results.get(1).contains("\"name\":\"c\""));
+ }
+ }
+
+ @Test
+ public void testIteratorProcessing() {
+ try (JqFilter filter = JqFilter.compile(library, ".[]")) {
+ Iterator iter = filter.processIterator("[\"a\", \"b\", \"c\"]");
+
+ assertTrue(iter.hasNext());
+ assertEquals("\"a\"", iter.next());
+ assertTrue(iter.hasNext());
+ assertEquals("\"b\"", iter.next());
+ assertTrue(iter.hasNext());
+ assertEquals("\"c\"", iter.next());
+ assertFalse(iter.hasNext());
+ }
+ }
+
+ @Test
+ public void testFilterReuse() {
+ try (JqFilter filter = JqFilter.compile(library, ".value")) {
+ // Process multiple inputs with the same filter
+ List results1 = filter.processAll("{\"value\": 1}");
+ List results2 = filter.processAll("{\"value\": 2}");
+ List results3 = filter.processAll("{\"value\": 3}");
+
+ assertEquals(1, results1.size());
+ assertEquals("1", results1.get(0));
+
+ assertEquals(1, results2.size());
+ assertEquals("2", results2.get(0));
+
+ assertEquals(1, results3.size());
+ assertEquals("3", results3.get(0));
+ }
+ }
+
+ @Test
+ public void testFilterReuseManyTimes() {
+ try (JqFilter filter = JqFilter.compile(library, ". + 1")) {
+ for (int i = 0; i < 100; i++) {
+ List results = filter.processAll(String.valueOf(i));
+ assertEquals(1, results.size());
+ assertEquals(String.valueOf(i + 1), results.get(0));
+ }
+ }
+ }
+
+ @Test
+ public void testPrettyPrint() {
+ try (JqFilter filter = JqFilter.builder()
+ .lib(library)
+ .filter(".")
+ .pretty(true)
+ .indent(Indent.TWO_SPACES)
+ .build()) {
+ List results = filter.processAll("{\"a\": 1, \"b\": 2}");
+
+ assertEquals(1, results.size());
+ assertTrue(results.get(0).contains("\n"));
+ }
+ }
+
+ @Test
+ public void testSortKeys() {
+ try (JqFilter filter = JqFilter.builder()
+ .lib(library)
+ .filter(".")
+ .sortKeys(true)
+ .build()) {
+ List results = filter.processAll("{\"z\": 1, \"a\": 2}");
+
+ assertEquals(1, results.size());
+ // Keys should be sorted alphabetically
+ assertTrue(results.get(0).indexOf("\"a\"") < results.get(0).indexOf("\"z\""));
+ }
+ }
+
+ @Test
+ public void testCompilationError() {
+ JqFilterException exception = assertThrows(JqFilterException.class, () -> {
+ JqFilter.compile(library, "invalid[[[filter");
+ });
+
+ assertNotNull(exception.getMessage());
+ assertFalse(exception.getErrors().isEmpty());
+ }
+
+ @Test
+ public void testProcessingError() {
+ try (JqFilter filter = JqFilter.compile(library, ".foo.bar")) {
+ // Processing null doesn't throw - it just produces null output
+ List results = filter.processAll("null");
+ assertEquals(1, results.size());
+ assertEquals("null", results.get(0));
+ }
+ }
+
+ @Test
+ public void testInvalidJson() {
+ try (JqFilter filter = JqFilter.compile(library, ".")) {
+ assertThrows(JqFilterException.class, () -> {
+ filter.processAll("not valid json");
+ });
+ }
+ }
+
+ @Test
+ public void testClosedFilterThrows() {
+ JqFilter filter = JqFilter.compile(library, ".");
+ filter.close();
+
+ assertThrows(IllegalStateException.class, () -> {
+ filter.processAll("{}");
+ });
+ }
+
+ @Test
+ public void testEmptyInput() {
+ try (JqFilter filter = JqFilter.compile(library, ".")) {
+ // Empty string should not produce any output
+ List results = filter.processAll("");
+ assertEquals(0, results.size());
+ }
+ }
+
+ @Test
+ public void testMultipleJsonValues() {
+ try (JqFilter filter = JqFilter.compile(library, ".")) {
+ // Multiple JSON values in sequence (NDJSON style)
+ List results = filter.processAll("{\"a\":1}\n{\"b\":2}\n{\"c\":3}");
+
+ assertEquals(3, results.size());
+ assertEquals("{\"a\":1}", results.get(0));
+ assertEquals("{\"b\":2}", results.get(1));
+ assertEquals("{\"c\":3}", results.get(2));
+ }
+ }
+
+ @Test
+ public void testComplexTransformation() {
+ String filter = ".items[] | {name: .name, value: (.price * .quantity)}";
+ try (JqFilter jqFilter = JqFilter.compile(library, filter)) {
+ String input = "{\"items\": [{\"name\": \"apple\", \"price\": 1.5, \"quantity\": 3}, {\"name\": \"banana\", \"price\": 0.5, \"quantity\": 6}]}";
+
+ List results = jqFilter.processAll(input);
+
+ assertEquals(2, results.size());
+ assertTrue(results.get(0).contains("\"apple\""));
+ assertTrue(results.get(0).contains("4.5"));
+ assertTrue(results.get(1).contains("\"banana\""));
+ assertTrue(results.get(1).contains("3"));
+ }
+ }
+
+ // Stream Processor Tests
+
+ @Test
+ public void testStreamProcessorBasic() {
+ try (JqFilter filter = JqFilter.compile(library, ".[]")) {
+ List results = new ArrayList<>();
+
+ try (JqStreamProcessor processor = filter.createStreamProcessor(results::add)) {
+ processor.feedChunk("[1, 2, 3]", true);
+ }
+
+ assertEquals(3, results.size());
+ assertEquals("1", results.get(0));
+ assertEquals("2", results.get(1));
+ assertEquals("3", results.get(2));
+ }
+ }
+
+ @Test
+ public void testStreamProcessorChunked() {
+ try (JqFilter filter = JqFilter.compile(library, ".items[]")) {
+ List results = new ArrayList<>();
+
+ try (JqStreamProcessor processor = filter.createStreamProcessor(results::add)) {
+ // Feed JSON in chunks
+ processor.feedChunk("{\"items\":", false);
+ processor.feedChunk(" [1, ", false);
+ processor.feedChunk("2, 3", false);
+ processor.feedChunk("]}", true);
+ }
+
+ assertEquals(3, results.size());
+ assertEquals("1", results.get(0));
+ assertEquals("2", results.get(1));
+ assertEquals("3", results.get(2));
+ }
+ }
+
+ @Test
+ public void testStreamProcessorMultipleObjects() {
+ try (JqFilter filter = JqFilter.compile(library, ".value")) {
+ List results = new ArrayList<>();
+
+ try (JqStreamProcessor processor = filter.createStreamProcessor(results::add)) {
+ // Feed multiple JSON objects (NDJSON style)
+ processor.feedChunk("{\"value\": 1}\n", false);
+ processor.feedChunk("{\"value\": 2}\n", false);
+ processor.feedChunk("{\"value\": 3}", true);
+ }
+
+ assertEquals(3, results.size());
+ assertEquals("1", results.get(0));
+ assertEquals("2", results.get(1));
+ assertEquals("3", results.get(2));
+ }
+ }
+
+ @Test
+ public void testStreamProcessorWithBytes() {
+ try (JqFilter filter = JqFilter.compile(library, ".")) {
+ List results = new ArrayList<>();
+
+ try (JqStreamProcessor processor = filter.createStreamProcessor(results::add)) {
+ processor.feedChunk("{\"test\":".getBytes(), false);
+ processor.feedChunk("\"value\"}".getBytes(), true);
+ }
+
+ assertEquals(1, results.size());
+ assertEquals("{\"test\":\"value\"}", results.get(0));
+ }
+ }
+
+ @Test
+ public void testStreamProcessorFinish() {
+ try (JqFilter filter = JqFilter.compile(library, ".")) {
+ List results = new ArrayList<>();
+
+ try (JqStreamProcessor processor = filter.createStreamProcessor(results::add)) {
+ assertFalse(processor.isFinished());
+ processor.feedChunk("{\"a\":1}", false);
+ assertFalse(processor.isFinished());
+ processor.finish();
+ assertTrue(processor.isFinished());
+ }
+
+ assertEquals(1, results.size());
+ }
+ }
+
+ @Test
+ public void testStreamProcessorDoubleFinishThrows() {
+ try (JqFilter filter = JqFilter.compile(library, ".")) {
+ try (JqStreamProcessor processor = filter.createStreamProcessor(s -> {})) {
+ processor.feedChunk("{}", true);
+
+ assertThrows(IllegalStateException.class, () -> {
+ processor.feedChunk("{}", false);
+ });
+ }
+ }
+ }
+
+ @Test
+ public void testStreamProcessorClosedThrows() {
+ try (JqFilter filter = JqFilter.compile(library, ".")) {
+ JqStreamProcessor processor = filter.createStreamProcessor(s -> {});
+ processor.close();
+
+ assertThrows(IllegalStateException.class, () -> {
+ processor.feedChunk("{}", true);
+ });
+ }
+ }
+
+ @Test
+ public void testStreamProcessorInvalidJson() {
+ try (JqFilter filter = JqFilter.compile(library, ".")) {
+ try (JqStreamProcessor processor = filter.createStreamProcessor(s -> {})) {
+ assertThrows(JqFilterException.class, () -> {
+ processor.feedChunk("invalid json {{{", true);
+ });
+ }
+ }
+ }
+
+ @Test
+ public void testLargeArrayStreaming() {
+ // Test with a larger array to verify streaming works
+ StringBuilder sb = new StringBuilder("[");
+ for (int i = 0; i < 1000; i++) {
+ if (i > 0) sb.append(",");
+ sb.append(i);
+ }
+ sb.append("]");
+
+ try (JqFilter filter = JqFilter.compile(library, ".[]")) {
+ List results = new ArrayList<>();
+ filter.process(sb.toString(), results::add);
+
+ assertEquals(1000, results.size());
+ for (int i = 0; i < 1000; i++) {
+ assertEquals(String.valueOf(i), results.get(i));
+ }
+ }
+ }
+
+ @Test
+ public void testModulePath() {
+ if (modulePath == null) {
+ return; // Skip if module path not available
+ }
+
+ try (JqFilter filter = JqFilter.builder()
+ .lib(library)
+ .filter("import \"a\" as a; a::a")
+ .addModulePath(modulePath)
+ .build()) {
+ List results = filter.processAll("null");
+
+ assertEquals(1, results.size());
+ assertEquals("\"a\"", results.get(0));
+ }
+ }
+
+ @Test
+ public void testBuilderDefaults() {
+ // Test that builder works with minimal configuration
+ try (JqFilter filter = JqFilter.builder()
+ .lib(library)
+ .build()) {
+ // Default filter is "."
+ assertEquals(".", filter.getFilter());
+
+ List results = filter.processAll("{\"x\":1}");
+ assertEquals(1, results.size());
+ assertEquals("{\"x\":1}", results.get(0));
+ }
+ }
+
+ @Test
+ public void testCallbackOrder() {
+ try (JqFilter filter = JqFilter.compile(library, ".[]")) {
+ List order = new ArrayList<>();
+ int[] counter = {0};
+
+ filter.process("[1, 2, 3, 4, 5]", output -> {
+ order.add(++counter[0]);
+ });
+
+ // Callbacks should be called in order
+ assertEquals(5, order.size());
+ for (int i = 0; i < 5; i++) {
+ assertEquals(i + 1, order.get(i));
+ }
+ }
+ }
+}