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:

+ * + * + *

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)); + } + } + } +}