jq 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/ext/jq/jq_ext.c ADDED
@@ -0,0 +1,385 @@
1
+ /* frozen_string_literal: true */
2
+
3
+ #include "jq_ext.h"
4
+ #include <string.h>
5
+
6
+ // Global variables for Ruby module and exception classes
7
+ VALUE rb_mJQ;
8
+ VALUE rb_eJQError;
9
+ VALUE rb_eJQCompileError;
10
+ VALUE rb_eJQRuntimeError;
11
+ VALUE rb_eJQParseError;
12
+
13
+ // Forward declarations for static helper functions
14
+ static VALUE jv_to_json_string(jv value, int raw, int compact, int sort);
15
+ static void raise_jq_error(jv error_value, VALUE exception_class);
16
+ static VALUE rb_jq_filter_impl(const char *json_str, const char *filter_str,
17
+ int raw_output, int compact_output,
18
+ int sort_keys, int multiple_outputs);
19
+
20
+ /**
21
+ * Convert a jv value to a Ruby JSON string
22
+ *
23
+ * @param value The jv value to convert (CONSUMED by this function)
24
+ * @param raw If true and value is string, return raw string (jq -r)
25
+ * @param compact If true, use compact JSON output (jq -c)
26
+ * @param sort If true, sort object keys (jq -S)
27
+ * @return Ruby string containing JSON or raw value
28
+ */
29
+ static VALUE jv_to_json_string(jv value, int raw, int compact, int sort) {
30
+ int flags = 0;
31
+ // Compact is the default; JV_PRINT_PRETTY makes it non-compact
32
+ if (!compact) flags |= JV_PRINT_PRETTY;
33
+ if (sort) flags |= JV_PRINT_SORTED;
34
+
35
+ jv json;
36
+ VALUE result;
37
+
38
+ // Raw output - return string directly without JSON encoding
39
+ if (raw && jv_get_kind(value) == JV_KIND_STRING) {
40
+ const char *str = jv_string_value(value);
41
+ size_t len = jv_string_length_bytes(jv_copy(value));
42
+ result = rb_utf8_str_new(str, len);
43
+ jv_free(value); // Free the string value
44
+ return result;
45
+ }
46
+
47
+ // Convert to JSON string
48
+ json = jv_dump_string(value, flags); // CONSUMES value
49
+
50
+ if (!jv_is_valid(json)) {
51
+ jv_free(json);
52
+ rb_raise(rb_eJQRuntimeError, "Failed to convert result to JSON");
53
+ }
54
+
55
+ const char *json_str = jv_string_value(json);
56
+ size_t json_len = jv_string_length_bytes(jv_copy(json));
57
+ result = rb_utf8_str_new(json_str, json_len);
58
+ jv_free(json); // Free the JSON string
59
+
60
+ return result;
61
+ }
62
+
63
+ /**
64
+ * Raise a Ruby exception from a jv error value
65
+ *
66
+ * @param error_value The jv error message (CONSUMED by this function)
67
+ * @param exception_class The Ruby exception class to raise
68
+ */
69
+ static void raise_jq_error(jv error_value, VALUE exception_class) {
70
+ if (!jv_is_valid(error_value) ||
71
+ jv_get_kind(error_value) != JV_KIND_STRING) {
72
+ jv_free(error_value);
73
+ rb_raise(exception_class, "Unknown jq error");
74
+ }
75
+
76
+ const char *msg = jv_string_value(error_value);
77
+ VALUE rb_msg = rb_str_new_cstr(msg);
78
+ jv_free(error_value); // Free the error message
79
+
80
+ // Store C string before rb_raise (StringValueCStr can raise if encoding issues occur)
81
+ const char *msg_cstr = StringValueCStr(rb_msg);
82
+ rb_raise(exception_class, "%s", msg_cstr);
83
+ }
84
+
85
+ /**
86
+ * Implementation of JQ.filter
87
+ *
88
+ * @param json_str JSON input string
89
+ * @param filter_str jq filter expression
90
+ * @param raw_output If true, output raw strings (jq -r)
91
+ * @param compact_output If true, output compact JSON (jq -c)
92
+ * @param sort_keys If true, sort object keys (jq -S)
93
+ * @param multiple_outputs If true, return array of all results
94
+ * @return Ruby string or array of strings
95
+ */
96
+ static VALUE rb_jq_filter_impl(const char *json_str, const char *filter_str,
97
+ int raw_output, int compact_output,
98
+ int sort_keys, int multiple_outputs) {
99
+ jq_state *jq = NULL;
100
+ jv input = jv_invalid();
101
+ VALUE results = Qnil;
102
+ jv result;
103
+
104
+ // Initialize jq
105
+ jq = jq_init();
106
+ if (!jq) {
107
+ rb_raise(rb_eJQError, "Failed to initialize jq");
108
+ }
109
+
110
+ // Compile filter
111
+ if (!jq_compile(jq, filter_str)) {
112
+ jv error = jq_get_error_message(jq);
113
+
114
+ if (jv_is_valid(error) && jv_get_kind(error) == JV_KIND_STRING) {
115
+ const char *error_msg = jv_string_value(error);
116
+ VALUE rb_error_msg = rb_str_new_cstr(error_msg);
117
+ jv_free(error);
118
+ // Store C string before cleanup (StringValueCStr can raise)
119
+ const char *error_cstr = StringValueCStr(rb_error_msg);
120
+ jq_teardown(&jq);
121
+ rb_raise(rb_eJQCompileError, "%s", error_cstr);
122
+ }
123
+
124
+ jv_free(error);
125
+ jq_teardown(&jq);
126
+ rb_raise(rb_eJQCompileError, "Syntax error in jq filter");
127
+ }
128
+
129
+ // Parse JSON input
130
+ input = jv_parse(json_str);
131
+ if (!jv_is_valid(input)) {
132
+ if (jv_invalid_has_msg(jv_copy(input))) {
133
+ jv error_msg = jv_invalid_get_msg(input); // CONSUMES input
134
+ jq_teardown(&jq);
135
+ raise_jq_error(error_msg, rb_eJQParseError);
136
+ }
137
+ jv_free(input);
138
+ jq_teardown(&jq);
139
+ rb_raise(rb_eJQParseError, "Invalid JSON input");
140
+ }
141
+
142
+ // Process with jq
143
+ jq_start(jq, input, 0); // CONSUMES input
144
+
145
+ // Collect results
146
+ if (multiple_outputs) {
147
+ results = rb_ary_new();
148
+
149
+ while (jv_is_valid(result = jq_next(jq))) {
150
+ VALUE json = jv_to_json_string(result, raw_output,
151
+ compact_output, sort_keys);
152
+ rb_ary_push(results, json);
153
+ }
154
+
155
+ // Check if the final invalid result has an error message
156
+ if (jv_invalid_has_msg(jv_copy(result))) {
157
+ jv error_msg = jv_invalid_get_msg(result); // CONSUMES result
158
+ jq_teardown(&jq);
159
+ raise_jq_error(error_msg, rb_eJQRuntimeError);
160
+ }
161
+
162
+ jv_free(result); // Free the invalid/end marker
163
+ } else {
164
+ result = jq_next(jq);
165
+
166
+ if (jv_is_valid(result)) {
167
+ results = jv_to_json_string(result, raw_output,
168
+ compact_output, sort_keys);
169
+ } else if (jv_invalid_has_msg(jv_copy(result))) {
170
+ jv error_msg = jv_invalid_get_msg(result); // CONSUMES result
171
+ jq_teardown(&jq);
172
+ raise_jq_error(error_msg, rb_eJQRuntimeError);
173
+ } else {
174
+ jv_free(result);
175
+ // No results - return null
176
+ results = rb_str_new_cstr("null");
177
+ }
178
+ }
179
+
180
+ jq_teardown(&jq);
181
+ return results;
182
+ }
183
+
184
+ /*
185
+ * call-seq:
186
+ * JQ.filter(json, filter, **options) -> String or Array<String>
187
+ *
188
+ * Apply a jq filter to JSON input and return the result.
189
+ *
190
+ * This is the primary method for using jq from Ruby. It parses the JSON input,
191
+ * compiles the filter expression, executes it, and returns the result as a
192
+ * JSON string (or array of strings with +multiple_outputs: true+).
193
+ *
194
+ * === Parameters
195
+ *
196
+ * [json (String)] Valid JSON input string
197
+ * [filter (String)] jq filter expression (e.g., ".name", ".[] | select(.age > 18)")
198
+ *
199
+ * === Options
200
+ *
201
+ * [:raw_output (Boolean)] Return raw strings without JSON encoding (equivalent to jq -r). Default: false
202
+ * [:compact_output (Boolean)] Output compact JSON on a single line. Default: true (set to false for pretty output)
203
+ * [:sort_keys (Boolean)] Sort object keys alphabetically (equivalent to jq -S). Default: false
204
+ * [:multiple_outputs (Boolean)] Return array of all results instead of just the first. Default: false
205
+ *
206
+ * === Returns
207
+ *
208
+ * [String] JSON-encoded result (default), or raw string if +raw_output: true+
209
+ * [Array<String>] Array of results if +multiple_outputs: true+
210
+ *
211
+ * === Raises
212
+ *
213
+ * [JQ::ParseError] If the JSON input is invalid
214
+ * [JQ::CompileError] If the jq filter expression is invalid
215
+ * [JQ::RuntimeError] If the filter execution fails
216
+ * [TypeError] If arguments are not strings
217
+ *
218
+ * === Examples
219
+ *
220
+ * # Basic filtering
221
+ * JQ.filter('{"name":"Alice","age":30}', '.name')
222
+ * # => "\"Alice\""
223
+ *
224
+ * # Raw output (no JSON encoding)
225
+ * JQ.filter('{"name":"Alice"}', '.name', raw_output: true)
226
+ * # => "Alice"
227
+ *
228
+ * # Pretty output
229
+ * JQ.filter('{"a":1,"b":2}', '.', compact_output: false)
230
+ * # => "{\n \"a\": 1,\n \"b\": 2\n}"
231
+ *
232
+ * # Multiple outputs
233
+ * JQ.filter('[1,2,3]', '.[]', multiple_outputs: true)
234
+ * # => ["1", "2", "3"]
235
+ *
236
+ * # Sort keys
237
+ * JQ.filter('{"z":1,"a":2}', '.', sort_keys: true)
238
+ * # => "{\"a\":2,\"z\":1}"
239
+ *
240
+ * # Complex transformation
241
+ * json = '[{"name":"Alice","age":30},{"name":"Bob","age":25}]'
242
+ * JQ.filter(json, '[.[] | select(.age > 26) | .name]')
243
+ * # => "[\"Alice\"]"
244
+ *
245
+ * === Thread Safety
246
+ *
247
+ * This method is thread-safe with jq 1.7+ (required by this gem). Each call
248
+ * creates an isolated jq_state, so concurrent calls do not interfere with
249
+ * each other.
250
+ *
251
+ */
252
+ VALUE rb_jq_filter(int argc, VALUE *argv, VALUE self) {
253
+ VALUE json_str, filter_str, opts;
254
+ rb_scan_args(argc, argv, "2:", &json_str, &filter_str, &opts);
255
+
256
+ Check_Type(json_str, T_STRING);
257
+ Check_Type(filter_str, T_STRING);
258
+
259
+ const char *json_cstr = StringValueCStr(json_str);
260
+ const char *filter_cstr = StringValueCStr(filter_str);
261
+
262
+ // Parse options (default to compact output)
263
+ int raw_output = 0, compact_output = 1;
264
+ int sort_keys = 0, multiple_outputs = 0;
265
+
266
+ if (!NIL_P(opts)) {
267
+ Check_Type(opts, T_HASH);
268
+ VALUE opt;
269
+
270
+ opt = rb_hash_aref(opts, ID2SYM(rb_intern("raw_output")));
271
+ if (RTEST(opt)) raw_output = 1;
272
+
273
+ opt = rb_hash_aref(opts, ID2SYM(rb_intern("compact_output")));
274
+ if (!NIL_P(opt)) compact_output = RTEST(opt) ? 1 : 0;
275
+
276
+ opt = rb_hash_aref(opts, ID2SYM(rb_intern("sort_keys")));
277
+ if (RTEST(opt)) sort_keys = 1;
278
+
279
+ opt = rb_hash_aref(opts, ID2SYM(rb_intern("multiple_outputs")));
280
+ if (RTEST(opt)) multiple_outputs = 1;
281
+ }
282
+
283
+ return rb_jq_filter_impl(json_cstr, filter_cstr,
284
+ raw_output, compact_output,
285
+ sort_keys, multiple_outputs);
286
+ }
287
+
288
+ /*
289
+ * call-seq:
290
+ * JQ.validate_filter!(filter) -> true
291
+ *
292
+ * Validate a jq filter expression without executing it.
293
+ *
294
+ * This method compiles the filter to check for syntax errors without requiring
295
+ * any JSON input. Use this to validate user-provided filters before attempting
296
+ * to apply them to data.
297
+ *
298
+ * === Parameters
299
+ *
300
+ * [filter (String)] jq filter expression to validate
301
+ *
302
+ * === Returns
303
+ *
304
+ * [true] Always returns true if the filter is valid
305
+ *
306
+ * === Raises
307
+ *
308
+ * [JQ::CompileError] If the filter expression is invalid
309
+ * [TypeError] If filter is not a string
310
+ *
311
+ * === Examples
312
+ *
313
+ * # Valid filters return true
314
+ * JQ.validate_filter!('.name')
315
+ * # => true
316
+ *
317
+ * JQ.validate_filter!('.[] | select(.age > 18)')
318
+ * # => true
319
+ *
320
+ * # Invalid filters raise CompileError
321
+ * JQ.validate_filter!('. @@@ .')
322
+ * # raises JQ::CompileError: Syntax error in jq filter
323
+ *
324
+ * # Validate user input before use
325
+ * user_filter = params[:filter]
326
+ * begin
327
+ * JQ.validate_filter!(user_filter)
328
+ * result = JQ.filter(json, user_filter)
329
+ * rescue JQ::CompileError => e
330
+ * puts "Invalid filter: #{e.message}"
331
+ * end
332
+ *
333
+ * === Thread Safety
334
+ *
335
+ * This method is thread-safe with jq 1.7+ (required by this gem).
336
+ *
337
+ */
338
+ VALUE rb_jq_validate_filter(VALUE self, VALUE filter) {
339
+ Check_Type(filter, T_STRING);
340
+ const char *filter_cstr = StringValueCStr(filter);
341
+
342
+ jq_state *jq = jq_init();
343
+ if (!jq) {
344
+ rb_raise(rb_eJQError, "Failed to initialize jq");
345
+ }
346
+
347
+ if (!jq_compile(jq, filter_cstr)) {
348
+ jv error = jq_get_error_message(jq);
349
+
350
+ if (jv_is_valid(error) && jv_get_kind(error) == JV_KIND_STRING) {
351
+ const char *error_msg = jv_string_value(error);
352
+ VALUE rb_error_msg = rb_str_new_cstr(error_msg);
353
+ jv_free(error);
354
+ // Store C string before cleanup (StringValueCStr can raise)
355
+ const char *error_cstr = StringValueCStr(rb_error_msg);
356
+ jq_teardown(&jq);
357
+ rb_raise(rb_eJQCompileError, "%s", error_cstr);
358
+ }
359
+
360
+ jv_free(error);
361
+ jq_teardown(&jq);
362
+ rb_raise(rb_eJQCompileError, "Syntax error in jq filter");
363
+ }
364
+
365
+ jq_teardown(&jq);
366
+ return Qtrue;
367
+ }
368
+
369
+ /**
370
+ * Initialize the jq extension
371
+ */
372
+ void Init_jq_ext(void) {
373
+ // Define module
374
+ rb_mJQ = rb_define_module("JQ");
375
+
376
+ // Define exception classes
377
+ rb_eJQError = rb_define_class_under(rb_mJQ, "Error", rb_eStandardError);
378
+ rb_eJQCompileError = rb_define_class_under(rb_mJQ, "CompileError", rb_eJQError);
379
+ rb_eJQRuntimeError = rb_define_class_under(rb_mJQ, "RuntimeError", rb_eJQError);
380
+ rb_eJQParseError = rb_define_class_under(rb_mJQ, "ParseError", rb_eJQError);
381
+
382
+ // Define methods
383
+ rb_define_singleton_method(rb_mJQ, "filter", rb_jq_filter, -1);
384
+ rb_define_singleton_method(rb_mJQ, "validate_filter!", rb_jq_validate_filter, 1);
385
+ }
data/ext/jq/jq_ext.h ADDED
@@ -0,0 +1,24 @@
1
+ /* frozen_string_literal: true */
2
+
3
+ #ifndef JQ_EXT_H
4
+ #define JQ_EXT_H
5
+
6
+ #include <ruby.h>
7
+ #include <jq.h>
8
+ #include <jv.h>
9
+
10
+ // Ruby module and exception classes
11
+ extern VALUE rb_mJQ;
12
+ extern VALUE rb_eJQError;
13
+ extern VALUE rb_eJQCompileError;
14
+ extern VALUE rb_eJQRuntimeError;
15
+ extern VALUE rb_eJQParseError;
16
+
17
+ // Main methods
18
+ VALUE rb_jq_filter(int argc, VALUE *argv, VALUE self);
19
+ VALUE rb_jq_validate_filter(VALUE self, VALUE filter);
20
+
21
+ // Initialization
22
+ void Init_jq_ext(void);
23
+
24
+ #endif /* JQ_EXT_H */
data/jq.gemspec ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "jq"
5
+ spec.version = "1.0.0"
6
+ spec.authors = ["Persona Identities"]
7
+ spec.email = ["rubygems@withpersona.com"]
8
+
9
+ spec.summary = "Ruby bindings for jq, the JSON processor"
10
+ spec.description = "A minimal, security-focused Ruby gem that wraps the jq C library for JSON transformation"
11
+ spec.homepage = "https://github.com/persona-id/jq-ruby"
12
+ spec.license = "MIT"
13
+ spec.required_ruby_version = ">= 3.3.0"
14
+
15
+ spec.metadata["homepage_uri"] = spec.homepage
16
+ spec.metadata["source_code_uri"] = spec.homepage
17
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
18
+ spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
19
+ spec.metadata["documentation_uri"] = "https://rubydoc.info/gems/jq"
20
+
21
+ # Specify which files should be added to the gem when it is released.
22
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
23
+ f.match(%r{^(test|spec|features)/})
24
+ end
25
+ spec.bindir = "exe"
26
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
27
+ spec.require_paths = ["lib"]
28
+ spec.extensions = ["ext/jq/extconf.rb"]
29
+
30
+ spec.add_dependency "mini_portile2", "~> 2.8"
31
+
32
+ spec.add_development_dependency "rake", "~> 13.0"
33
+ spec.add_development_dependency "rake-compiler", "~> 1.2"
34
+ spec.add_development_dependency "rspec", "~> 3.13"
35
+ end
data/lib/jq.rb ADDED
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # JQ provides Ruby bindings for the jq JSON processor.
5
+ #
6
+ # This gem wraps the jq C library, allowing you to apply jq filters to JSON
7
+ # strings directly from Ruby. It supports all standard jq operations and
8
+ # provides a clean Ruby API with proper error handling.
9
+ #
10
+ # === Thread Safety
11
+ #
12
+ # This gem requires jq 1.7+ for safe multi-threaded operation. Each method
13
+ # call creates an isolated jq_state, and jq 1.7+ includes critical thread
14
+ # safety fixes (PR #2546). Safe to use from multiple threads in MRI Ruby.
15
+ #
16
+ # === Basic Usage
17
+ #
18
+ # require 'jq'
19
+ #
20
+ # # Simple filtering
21
+ # JQ.filter('{"name":"Alice"}', '.name')
22
+ # # => "\"Alice\""
23
+ #
24
+ # # With options
25
+ # JQ.filter('{"name":"Alice"}', '.name', raw_output: true)
26
+ # # => "Alice"
27
+ #
28
+ # # Multiple outputs
29
+ # JQ.filter('[1,2,3]', '.[]', multiple_outputs: true)
30
+ # # => ["1", "2", "3"]
31
+ #
32
+ # === Error Handling
33
+ #
34
+ # All jq-related errors inherit from JQ::Error:
35
+ #
36
+ # begin
37
+ # JQ.filter('invalid', '.')
38
+ # rescue JQ::ParseError => e
39
+ # puts "Invalid JSON: #{e.message}"
40
+ # end
41
+ #
42
+ # @see https://jqlang.github.io/jq/ jq documentation
43
+ #
44
+ module JQ
45
+ ##
46
+ # The gem version number
47
+ #
48
+ VERSION = "1.0.0"
49
+
50
+ ##
51
+ # Base exception class for all jq-related errors.
52
+ #
53
+ # All other jq exception classes inherit from this, allowing you to
54
+ # rescue all jq errors with a single rescue clause:
55
+ #
56
+ # begin
57
+ # JQ.filter(json, filter)
58
+ # rescue JQ::Error => e
59
+ # puts "JQ error: #{e.message}"
60
+ # end
61
+ #
62
+ class Error < StandardError; end
63
+
64
+ ##
65
+ # Raised when a jq filter expression fails to compile.
66
+ #
67
+ # This typically indicates a syntax error in the filter expression:
68
+ #
69
+ # JQ.filter('{}', '. @@@ .')
70
+ # # raises JQ::CompileError: Syntax error in jq filter
71
+ #
72
+ class CompileError < Error; end
73
+
74
+ ##
75
+ # Raised when a jq filter fails during execution.
76
+ #
77
+ # This typically indicates a type mismatch or invalid operation:
78
+ #
79
+ # JQ.filter('42', '.[]')
80
+ # # raises JQ::RuntimeError: Cannot iterate over number
81
+ #
82
+ class RuntimeError < Error; end
83
+
84
+ ##
85
+ # Raised when JSON input is invalid or malformed.
86
+ #
87
+ # This indicates the input string is not valid JSON:
88
+ #
89
+ # JQ.filter('not json', '.')
90
+ # # raises JQ::ParseError: Invalid JSON input
91
+ #
92
+ class ParseError < Error; end
93
+ end
94
+
95
+ begin
96
+ require_relative "jq/jq_ext"
97
+ rescue LoadError => e
98
+ raise LoadError, "Failed to load jq extension. " \
99
+ "Please run 'rake compile' to build the extension. " \
100
+ "Original error: #{e.message}"
101
+ end
data/sig/jq.rbs ADDED
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JQ
4
+ VERSION: String
5
+
6
+ # Apply a jq filter to JSON input
7
+ #
8
+ # @param json The JSON input as a string
9
+ # @param filter The jq filter expression
10
+ # @param raw_output Return raw strings without JSON encoding (jq -r)
11
+ # @param compact_output Output compact JSON (default: true). Set to false for pretty output
12
+ # @param sort_keys Sort object keys (jq -S)
13
+ # @param multiple_outputs Return array of all results instead of first only
14
+ # @return The filtered result as JSON string, or array of strings if multiple_outputs
15
+ def self.filter: (String json, String filter,
16
+ ?raw_output: bool,
17
+ ?compact_output: bool,
18
+ ?sort_keys: bool,
19
+ ?multiple_outputs: false) -> String
20
+ | (String json, String filter,
21
+ ?raw_output: bool,
22
+ ?compact_output: bool,
23
+ ?sort_keys: bool,
24
+ multiple_outputs: true) -> Array[String]
25
+
26
+ # Validate a jq filter expression
27
+ #
28
+ # @param filter The jq filter expression to validate
29
+ # @return true if valid
30
+ # @raise [CompileError] if invalid
31
+ def self.validate_filter!: (String filter) -> true
32
+
33
+ # Base exception class for all jq-related errors
34
+ class Error < StandardError
35
+ end
36
+
37
+ # Raised when a jq filter fails to compile
38
+ class CompileError < Error
39
+ end
40
+
41
+ # Raised when a jq filter execution fails at runtime
42
+ class RuntimeError < Error
43
+ end
44
+
45
+ # Raised when JSON input is invalid
46
+ class ParseError < Error
47
+ end
48
+ end