From 1832a61d7384fa52e70698a8d8896bc7968e25be Mon Sep 17 00:00:00 2001 From: Alvaro Frias Garay Date: Sun, 29 Mar 2026 18:17:48 -0300 Subject: [PATCH 1/4] add functions & expref - fix errors messages Signed-off-by: Alvaro Frias Garay --- spec/error_messages_spec.cr | 143 +++++++++++ spec/functions_spec.cr | 492 ++++++++++++++++++++++++++++++++++++ src/exceptions.cr | 45 +++- src/functions.cr | 395 +++++++++++++++++++++++++++++ src/jmespath.cr | 1 + src/lexer.cr | 12 +- src/nodes.cr | 9 +- src/parser.cr | 54 ++-- 8 files changed, 1109 insertions(+), 42 deletions(-) create mode 100644 spec/error_messages_spec.cr create mode 100644 spec/functions_spec.cr create mode 100644 src/functions.cr diff --git a/spec/error_messages_spec.cr b/spec/error_messages_spec.cr new file mode 100644 index 0000000..229fe3b --- /dev/null +++ b/spec/error_messages_spec.cr @@ -0,0 +1,143 @@ +require "./spec_helper" + +describe "Error messages" do + describe "ParseError" do + it "reports correct position for trailing tokens" do + parser = Parser.new + ex = expect_raises(ParseError) { parser.parse("foo bar") } + ex.lex_position.should eq(4) + ex.message.not_nil!.should contain("foo bar") + ex.message.not_nil!.should contain("at column 4") + end + + it "reports correct position for unexpected token in expression" do + parser = Parser.new + ex = expect_raises(ParseError) { parser.parse("foo.||") } + ex.message.not_nil!.should contain("foo.||") + end + + it "reports correct position for invalid dot RHS" do + parser = Parser.new + ex = expect_raises(ParseError) { parser.parse("foo.)") } + ex.lex_position.should eq(4) + ex.message.not_nil!.should contain("foo.)") + ex.message.not_nil!.should contain("at column 4") + end + + it "reports correct position for unmatched bracket" do + parser = Parser.new + ex = expect_raises(ParseError) { parser.parse("foo[0") } + ex.message.not_nil!.should contain("foo[0") + end + + it "reports correct position for invalid slice syntax" do + parser = Parser.new + ex = expect_raises(ParseError) { parser.parse("foo[0:a]") } + ex.lex_position.should eq(6) + ex.message.not_nil!.should contain("foo[0:a]") + end + + it "reports correct position for unmatched paren" do + parser = Parser.new + ex = expect_raises(ParseError) { parser.parse("(foo") } + ex.message.not_nil!.should contain("(foo") + end + + it "reports correct position for invalid bracket operation" do + parser = Parser.new + ex = expect_raises(ParseError) { parser.parse("foo[)") } + ex.lex_position.should eq(4) + ex.message.not_nil!.should contain("foo[)") + end + + it "reports correct position for quoted identifier as function name" do + parser = Parser.new + ex = expect_raises(ParseError) { parser.parse("\"foo\"()") } + ex.message.not_nil!.should contain("Quoted identifier not allowed for function names") + ex.message.not_nil!.should contain("\"foo\"()") + end + + it "reports position for invalid multi-select hash key" do + parser = Parser.new + ex = expect_raises(ParseError) { parser.parse("{123: foo}") } + ex.message.not_nil!.should contain("{123: foo}") + end + + it "includes expression and caret in error message" do + parser = Parser.new + ex = expect_raises(ParseError) { parser.parse("foo bar") } + lines = ex.message.not_nil!.split("\n") + # Should have: message line, expression line, caret line + lines.size.should eq(3) + lines[1].should eq("foo bar") + lines[2].should contain("^") + end + end + + describe "LexerError" do + it "reports correct position for unknown token" do + lexer = Lexer.new + ex = expect_raises(LexerError) { lexer.tokenize("foo^bar") } + ex.lexer_position.should eq(3) + ex.message.not_nil!.should contain("foo^bar") + ex.message.not_nil!.should contain("^") + end + + it "reports position for unknown token at start" do + lexer = Lexer.new + ex = expect_raises(LexerError) { lexer.tokenize("^foo") } + ex.lexer_position.should eq(0) + ex.message.not_nil!.should contain("^foo") + end + + it "reports position for unclosed string literal" do + lexer = Lexer.new + ex = expect_raises(LexerError) { lexer.tokenize("'unclosed") } + ex.message.not_nil!.should contain("Unclosed literal") + ex.message.not_nil!.should contain("'unclosed") + end + + it "reports position for unclosed backtick literal" do + lexer = Lexer.new + ex = expect_raises(LexerError) { lexer.tokenize("`unclosed") } + ex.message.not_nil!.should contain("Unclosed literal") + ex.message.not_nil!.should contain("`unclosed") + end + + it "reports position for lone equals sign" do + lexer = Lexer.new + ex = expect_raises(LexerError) { lexer.tokenize("foo =") } + ex.message.not_nil!.should contain("Unknown token '='") + end + + it "reports position for invalid negative number" do + lexer = Lexer.new + ex = expect_raises(LexerError) { lexer.tokenize("foo[-abc]") } + ex.message.not_nil!.should contain("Unknown token '-'") + ex.message.not_nil!.should contain("foo[-abc]") + end + + it "includes expression and caret in error message" do + lexer = Lexer.new + ex = expect_raises(LexerError) { lexer.tokenize("foo^bar") } + lines = ex.message.not_nil!.split("\n") + lines.size.should eq(3) + lines[1].should eq("foo^bar") + # Caret should be at position 3 + lines[2].should eq(" ^") + end + end + + describe "integration error messages" do + it "JMESPath.search shows correct position" do + ex = expect_raises(ParseError) { JMESPath.search("foo.[}", %({"foo": 1})) } + ex.message.not_nil!.should contain("foo.[}") + end + + it "shows position for deeply nested error" do + ex = expect_raises(ParseError) { JMESPath.search("foo.bar.baz[0].qux.)", %({})) } + ex.lex_position.should eq(19) + ex.message.not_nil!.should contain("at column 19") + end + end +end diff --git a/spec/functions_spec.cr b/spec/functions_spec.cr new file mode 100644 index 0000000..c79811c --- /dev/null +++ b/spec/functions_spec.cr @@ -0,0 +1,492 @@ +require "./spec_helper" + +describe "JMESPath built-in functions" do + # -- Number functions -- + + describe "abs" do + it "returns absolute value of positive number" do + JMESPath.search("abs(foo)", %({"foo": 5})).as_i.should eq(5) + end + + it "returns absolute value of negative number" do + JMESPath.search("abs(foo)", %({"foo": -5})).as_i.should eq(5) + end + + it "raises on non-number" do + expect_raises(JMESPathTypeError) do + JMESPath.search("abs(foo)", %({"foo": "string"})) + end + end + end + + describe "avg" do + it "computes average of number array" do + JMESPath.search("avg(foo)", %({"foo": [10, 20, 30]})).as_f.should eq(20.0) + end + + it "returns null for empty array" do + JMESPath.search("avg(foo)", %({"foo": []})).raw.should be_nil + end + end + + describe "ceil" do + it "rounds up a float" do + JMESPath.search("ceil(`1.2`)", %({})).as_i.should eq(2) + end + + it "returns integer unchanged" do + JMESPath.search("ceil(`5`)", %({})).as_i.should eq(5) + end + end + + describe "floor" do + it "rounds down a float" do + JMESPath.search("floor(`1.9`)", %({})).as_i.should eq(1) + end + + it "returns integer unchanged" do + JMESPath.search("floor(`5`)", %({})).as_i.should eq(5) + end + end + + describe "sum" do + it "sums integer array" do + JMESPath.search("sum(foo)", %({"foo": [1, 2, 3]})).as_i.should eq(6) + end + + it "returns 0 for empty array" do + JMESPath.search("sum(foo)", %({"foo": []})).as_i.should eq(0) + end + end + + # -- String / array functions -- + + describe "length" do + it "returns length of string" do + JMESPath.search("length(foo)", %({"foo": "hello"})).as_i.should eq(5) + end + + it "returns length of array" do + JMESPath.search("length(foo)", %({"foo": [1, 2, 3]})).as_i.should eq(3) + end + + it "returns length of object" do + JMESPath.search("length(foo)", %({"foo": {"a": 1, "b": 2}})).as_i.should eq(2) + end + end + + describe "reverse" do + it "reverses a string" do + JMESPath.search("reverse(foo)", %({"foo": "hello"})).as_s.should eq("olleh") + end + + it "reverses an array" do + result = JMESPath.search("reverse(foo)", %({"foo": [1, 2, 3]})).as_a + result.map(&.as_i).should eq([3, 2, 1]) + end + end + + describe "sort" do + it "sorts string array" do + result = JMESPath.search("sort(foo)", %({"foo": ["c", "a", "b"]})).as_a + result.map(&.as_s).should eq(["a", "b", "c"]) + end + + it "sorts number array" do + result = JMESPath.search("sort(foo)", %({"foo": [3, 1, 2]})).as_a + result.map(&.as_i).should eq([1, 2, 3]) + end + + it "returns empty array unchanged" do + JMESPath.search("sort(foo)", %({"foo": []})).as_a.size.should eq(0) + end + end + + describe "join" do + it "joins array with separator" do + JMESPath.search("join(', ', foo)", %({"foo": ["a", "b", "c"]})).as_s.should eq("a, b, c") + end + + it "joins with empty separator" do + JMESPath.search("join('', foo)", %({"foo": ["a", "b"]})).as_s.should eq("ab") + end + end + + describe "contains" do + it "checks string contains substring" do + JMESPath.search("contains(foo, 'll')", %({"foo": "hello"})).as_bool.should be_true + end + + it "returns false for missing substring" do + JMESPath.search("contains(foo, 'xyz')", %({"foo": "hello"})).as_bool.should be_false + end + + it "checks array contains element" do + JMESPath.search("contains(foo, `2`)", %({"foo": [1, 2, 3]})).as_bool.should be_true + end + + it "returns false for missing element" do + JMESPath.search("contains(foo, `5`)", %({"foo": [1, 2, 3]})).as_bool.should be_false + end + end + + describe "starts_with" do + it "returns true for matching prefix" do + JMESPath.search("starts_with(foo, 'hel')", %({"foo": "hello"})).as_bool.should be_true + end + + it "returns false for non-matching prefix" do + JMESPath.search("starts_with(foo, 'xyz')", %({"foo": "hello"})).as_bool.should be_false + end + end + + describe "ends_with" do + it "returns true for matching suffix" do + JMESPath.search("ends_with(foo, 'llo')", %({"foo": "hello"})).as_bool.should be_true + end + + it "returns false for non-matching suffix" do + JMESPath.search("ends_with(foo, 'xyz')", %({"foo": "hello"})).as_bool.should be_false + end + end + + # -- Object functions -- + + describe "keys" do + it "returns object keys" do + result = JMESPath.search("keys(foo)", %({"foo": {"a": 1, "b": 2}})).as_a + result.map(&.as_s).sort.should eq(["a", "b"]) + end + end + + describe "values" do + it "returns object values" do + result = JMESPath.search("values(foo)", %({"foo": {"a": 1, "b": 2}})).as_a + result.map(&.as_i).sort.should eq([1, 2]) + end + end + + describe "merge" do + it "merges two objects" do + result = JMESPath.search("merge(foo, bar)", %({"foo": {"a": 1}, "bar": {"b": 2}})).as_h + result["a"].as_i.should eq(1) + result["b"].as_i.should eq(2) + end + + it "later values override earlier" do + result = JMESPath.search("merge(foo, bar)", %({"foo": {"a": 1}, "bar": {"a": 2}})).as_h + result["a"].as_i.should eq(2) + end + end + + # -- Conversion functions -- + + describe "type" do + it "returns type names" do + JMESPath.search("type(foo)", %({"foo": "hello"})).as_s.should eq("string") + JMESPath.search("type(foo)", %({"foo": 42})).as_s.should eq("number") + JMESPath.search("type(foo)", %({"foo": true})).as_s.should eq("boolean") + JMESPath.search("type(foo)", %({"foo": null})).as_s.should eq("null") + JMESPath.search("type(foo)", %({"foo": [1]})).as_s.should eq("array") + JMESPath.search("type(foo)", %({"foo": {"a": 1}})).as_s.should eq("object") + end + end + + describe "to_array" do + it "wraps non-array in array" do + result = JMESPath.search("to_array(foo)", %({"foo": "hello"})).as_a + result.size.should eq(1) + result[0].as_s.should eq("hello") + end + + it "returns array unchanged" do + result = JMESPath.search("to_array(foo)", %({"foo": [1, 2]})).as_a + result.map(&.as_i).should eq([1, 2]) + end + end + + describe "to_string" do + it "returns string unchanged" do + JMESPath.search("to_string(foo)", %({"foo": "hello"})).as_s.should eq("hello") + end + + it "converts number to string" do + JMESPath.search("to_string(foo)", %({"foo": 42})).as_s.should eq("42") + end + end + + describe "to_number" do + it "returns number unchanged" do + JMESPath.search("to_number(foo)", %({"foo": 42})).as_i.should eq(42) + end + + it "converts numeric string to number" do + JMESPath.search("to_number(foo)", %({"foo": "42"})).as_i.should eq(42) + end + + it "returns null for non-numeric string" do + JMESPath.search("to_number(foo)", %({"foo": "abc"})).raw.should be_nil + end + + it "returns null for boolean" do + JMESPath.search("to_number(foo)", %({"foo": true})).raw.should be_nil + end + end + + # -- Min / max -- + + describe "max" do + it "finds max in number array" do + JMESPath.search("max(foo)", %({"foo": [3, 1, 5, 2]})).as_i.should eq(5) + end + + it "finds max in string array" do + JMESPath.search("max(foo)", %({"foo": ["b", "a", "c"]})).as_s.should eq("c") + end + + it "returns null for empty array" do + JMESPath.search("max(foo)", %({"foo": []})).raw.should be_nil + end + end + + describe "min" do + it "finds min in number array" do + JMESPath.search("min(foo)", %({"foo": [3, 1, 5, 2]})).as_i.should eq(1) + end + + it "finds min in string array" do + JMESPath.search("min(foo)", %({"foo": ["b", "a", "c"]})).as_s.should eq("a") + end + end + + describe "not_null" do + it "returns first non-null" do + JMESPath.search("not_null(a, b, c)", %({"a": null, "b": "hello", "c": "world"})).as_s.should eq("hello") + end + + it "returns null when all null" do + JMESPath.search("not_null(a, b)", %({"a": null, "b": null})).raw.should be_nil + end + end + + # -- Expref functions -- + + describe "sort_by" do + it "sorts objects by expression" do + data = %({"people": [{"name": "bob", "age": 30}, {"name": "alice", "age": 25}, {"name": "charlie", "age": 35}]}) + result = JMESPath.search("sort_by(people, &age)", data).as_a + result.map { |r| r["name"].as_s }.should eq(["alice", "bob", "charlie"]) + end + + it "sorts by string expression" do + data = %({"people": [{"name": "bob"}, {"name": "alice"}, {"name": "charlie"}]}) + result = JMESPath.search("sort_by(people, &name)", data).as_a + result.map { |r| r["name"].as_s }.should eq(["alice", "bob", "charlie"]) + end + + it "returns empty array unchanged" do + JMESPath.search("sort_by(foo, &bar)", %({"foo": []})).as_a.size.should eq(0) + end + end + + describe "max_by" do + it "finds max by expression" do + data = %({"people": [{"name": "bob", "age": 30}, {"name": "alice", "age": 25}, {"name": "charlie", "age": 35}]}) + JMESPath.search("max_by(people, &age)", data).as_h["name"].as_s.should eq("charlie") + end + + it "returns null for empty array" do + JMESPath.search("max_by(foo, &bar)", %({"foo": []})).raw.should be_nil + end + end + + describe "min_by" do + it "finds min by expression" do + data = %({"people": [{"name": "bob", "age": 30}, {"name": "alice", "age": 25}, {"name": "charlie", "age": 35}]}) + JMESPath.search("min_by(people, &age)", data).as_h["name"].as_s.should eq("alice") + end + end + + describe "map" do + it "maps expression over array" do + data = %({"people": [{"name": "bob", "age": 30}, {"name": "alice", "age": 25}]}) + result = JMESPath.search("map(&age, people)", data).as_a + result.map(&.as_i).should eq([30, 25]) + end + end + + # -- Expref (expression reference) edge cases -- + + describe "expref" do + it "sort_by with nested key access" do + data = %({"items": [ + {"info": {"priority": 3}}, + {"info": {"priority": 1}}, + {"info": {"priority": 2}} + ]}) + result = JMESPath.search("sort_by(items, &info.priority)", data).as_a + result.map { |r| r["info"]["priority"].as_i }.should eq([1, 2, 3]) + end + + it "map with nested key access" do + data = %({"items": [ + {"info": {"name": "a"}}, + {"info": {"name": "b"}} + ]}) + result = JMESPath.search("map(&info.name, items)", data).as_a + result.map(&.as_s).should eq(["a", "b"]) + end + + it "max_by with nested key" do + data = %({"items": [ + {"stats": {"score": 10}}, + {"stats": {"score": 50}}, + {"stats": {"score": 30}} + ]}) + JMESPath.search("max_by(items, &stats.score)", data).as_h["stats"]["score"].as_i.should eq(50) + end + + it "min_by with nested key" do + data = %({"items": [ + {"stats": {"score": 10}}, + {"stats": {"score": 50}}, + {"stats": {"score": 30}} + ]}) + JMESPath.search("min_by(items, &stats.score)", data).as_h["stats"]["score"].as_i.should eq(10) + end + + it "sort_by then project a field" do + data = %({"people": [ + {"name": "charlie", "age": 35}, + {"name": "alice", "age": 25}, + {"name": "bob", "age": 30} + ]}) + result = JMESPath.search("sort_by(people, &age)[*].name", data).as_a + result.map(&.as_s).should eq(["alice", "bob", "charlie"]) + end + + it "sort_by result piped to reverse" do + data = %({"people": [ + {"name": "charlie", "age": 35}, + {"name": "alice", "age": 25}, + {"name": "bob", "age": 30} + ]}) + result = JMESPath.search("sort_by(people, &age) | reverse(@)", data).as_a + result.map { |r| r["name"].as_s }.should eq(["charlie", "bob", "alice"]) + end + + it "max_by with string comparison" do + data = %({"items": [{"id": "alpha"}, {"id": "gamma"}, {"id": "beta"}]}) + JMESPath.search("max_by(items, &id)", data).as_h["id"].as_s.should eq("gamma") + end + + it "min_by with string comparison" do + data = %({"items": [{"id": "alpha"}, {"id": "gamma"}, {"id": "beta"}]}) + JMESPath.search("min_by(items, &id)", data).as_h["id"].as_s.should eq("alpha") + end + + it "map with length function in expression" do + data = %({"words": ["hello", "hi", "hey"]}) + result = JMESPath.search("map(&length(@), words)", data).as_a + result.map(&.as_i).should eq([5, 2, 3]) + end + + it "sort_by combined with max_by" do + data = %({"groups": [ + {"name": "a", "values": [1, 2, 3]}, + {"name": "b", "values": [10, 20, 30, 40]}, + {"name": "c", "values": [5]} + ]}) + result = JMESPath.search("max_by(groups, &length(values))", data).as_h + result["name"].as_s.should eq("b") + end + + it "map expref with index access" do + data = %({"items": [[1, 2, 3], [4, 5, 6], [7, 8, 9]]}) + result = JMESPath.search("map(&[0], items)", data).as_a + result.map(&.as_i).should eq([1, 4, 7]) + end + + it "sort_by with filter on result" do + data = %({"people": [ + {"name": "alice", "age": 25}, + {"name": "bob", "age": 30}, + {"name": "charlie", "age": 20} + ]}) + result = JMESPath.search("sort_by(people, &age)[?age > `22`].name", data).as_a + result.map(&.as_s).should eq(["alice", "bob"]) + end + + it "map with multi-select hash" do + data = %({"people": [ + {"first": "Alice", "last": "Smith", "age": 25}, + {"first": "Bob", "last": "Jones", "age": 30} + ]}) + result = JMESPath.search("map(&{name: first, surname: last}, people)", data).as_a + result[0].as_h["name"].as_s.should eq("Alice") + result[0].as_h["surname"].as_s.should eq("Smith") + result[1].as_h["name"].as_s.should eq("Bob") + end + end + + # -- Error handling -- + + describe "error handling" do + it "raises on unknown function" do + expect_raises(UnknownFunctionError) do + JMESPath.search("unknown_func(foo)", %({"foo": 1})) + end + end + + it "raises on wrong arity" do + expect_raises(ArityError) do + JMESPath.search("abs(foo, bar)", %({"foo": 1, "bar": 2})) + end + end + + it "raises on wrong type" do + expect_raises(JMESPathTypeError) do + JMESPath.search("length(`42`)", %({})) + end + end + end + + # -- Combined expressions -- + + describe "combined with other features" do + it "uses functions with projections" do + data = %({"people": [{"name": "bob"}, {"name": "alice"}, {"name": "charlie"}]}) + result = JMESPath.search("length(people[*].name)", data).as_i + result.should eq(3) + end + + it "uses functions with filters" do + data = %({"people": [{"name": "bob", "age": 20}, {"name": "alice", "age": 25}, {"name": "charlie", "age": 30}]}) + result = JMESPath.search("length(people[?age > `20`])", data).as_i + result.should eq(2) + end + + it "uses functions in pipe expressions" do + data = %({"foo": [3, 1, 2]}) + result = JMESPath.search("foo | sort(@) | [0]", data).as_i + result.should eq(1) + end + + it "chains function results" do + data = %({"foo": "hello world"}) + result = JMESPath.search("length(foo)", data).as_i + result.should eq(11) + end + + it "sort_by with filter" do + data = %({"people": [ + {"name": "bob", "age": 30}, + {"name": "alice", "age": 25}, + {"name": "charlie", "age": 35}, + {"name": "dave", "age": 20} + ]}) + result = JMESPath.search("sort_by(people[?age > `22`], &age)[*].name", data).as_a + result.map(&.as_s).should eq(["alice", "bob", "charlie"]) + end + end +end diff --git a/src/exceptions.cr b/src/exceptions.cr index 4f807de..fa6ec0b 100644 --- a/src/exceptions.cr +++ b/src/exceptions.cr @@ -1,10 +1,17 @@ class LexerError < Exception getter lexer_position : Int32 getter lexer_value : String - property expression : String? + getter expression : String? def initialize(@lexer_position : Int32, @lexer_value : String, message : String, @expression : String? = nil) - super("#{message}: Bad jmespath expression at position #{lexer_position}\n#{expression}") + msg = String.build do |io| + io << message + if expr = @expression + io << "\n" << expr << "\n" + io << " " * @lexer_position << "^" + end + end + super(msg) end end @@ -12,11 +19,19 @@ class ParseError < Exception getter lex_position : Int32 getter token_value : String getter token_type : String - property expression : String? - property msg : String = "Invalid jmespath expression" + getter expression : String? - def initialize(@lex_position : Int32, @token_value : String, @token_type : String, msg : String = "Invalid jmespath expression") - super("#{msg}: Parse error at column #{lex_position}, token \"#{token_value}\" (#{token_type}), for expression:\n\"#{expression}\"\n#{" " * (lex_position + 1)}^") + def initialize(@lex_position : Int32, @token_value : String, @token_type : String, + msg : String = "Invalid jmespath expression", @expression : String? = nil) + full_msg = String.build do |io| + io << msg + io << " (at column #{@lex_position})" + if expr = @expression + io << "\n" << expr << "\n" + io << " " * @lex_position << "^" + end + end + super(full_msg) end end @@ -25,3 +40,21 @@ class EmptyExpressionError < Exception super("Invalid JMESPath expression: cannot be empty.") end end + +class UnknownFunctionError < Exception + def initialize(name : String) + super("Unknown function: #{name}()") + end +end + +class ArityError < Exception + def initialize(name : String, expected : String, actual : Int32) + super("Expected #{expected} argument(s) for function #{name}(), received #{actual}") + end +end + +class JMESPathTypeError < Exception + def initialize(name : String, actual : String, expected : String) + super("Invalid type for function #{name}(): expected #{expected}, got #{actual}") + end +end diff --git a/src/functions.cr b/src/functions.cr new file mode 100644 index 0000000..5c4dd47 --- /dev/null +++ b/src/functions.cr @@ -0,0 +1,395 @@ +require "json" +require "./exceptions" + +class FunctionRuntime + ARITY = { + "abs" => {1, 1}, + "avg" => {1, 1}, + "ceil" => {1, 1}, + "contains" => {2, 2}, + "ends_with" => {2, 2}, + "floor" => {1, 1}, + "join" => {2, 2}, + "keys" => {1, 1}, + "length" => {1, 1}, + "map" => {2, 2}, + "max" => {1, 1}, + "max_by" => {2, 2}, + "merge" => {1, -1}, + "min" => {1, 1}, + "min_by" => {2, 2}, + "not_null" => {1, -1}, + "reverse" => {1, 1}, + "sort" => {1, 1}, + "sort_by" => {2, 2}, + "starts_with" => {2, 2}, + "sum" => {1, 1}, + "to_array" => {1, 1}, + "to_number" => {1, 1}, + "to_string" => {1, 1}, + "type" => {1, 1}, + "values" => {1, 1}, + } + + def self.invoke(name : String, args : Array(Node), current : JSON::Any) : JSON::Any + unless ARITY.has_key?(name) + raise UnknownFunctionError.new(name) + end + + validate_arity(name, args.size) + + case name + when "abs" then func_abs(eval(args, 0, current)) + when "avg" then func_avg(eval(args, 0, current)) + when "ceil" then func_ceil(eval(args, 0, current)) + when "contains" then func_contains(eval(args, 0, current), eval(args, 1, current)) + when "ends_with" then func_ends_with(eval(args, 0, current), eval(args, 1, current)) + when "floor" then func_floor(eval(args, 0, current)) + when "join" then func_join(eval(args, 0, current), eval(args, 1, current)) + when "keys" then func_keys(eval(args, 0, current)) + when "length" then func_length(eval(args, 0, current)) + when "map" then func_map(expref(args, 0), eval(args, 1, current)) + when "max" then func_max(eval(args, 0, current)) + when "max_by" then func_max_by(eval(args, 0, current), expref(args, 1)) + when "merge" then func_merge(eval_all(args, current)) + when "min" then func_min(eval(args, 0, current)) + when "min_by" then func_min_by(eval(args, 0, current), expref(args, 1)) + when "not_null" then func_not_null(eval_all(args, current)) + when "reverse" then func_reverse(eval(args, 0, current)) + when "sort" then func_sort(eval(args, 0, current)) + when "sort_by" then func_sort_by(eval(args, 0, current), expref(args, 1)) + when "starts_with" then func_starts_with(eval(args, 0, current), eval(args, 1, current)) + when "sum" then func_sum(eval(args, 0, current)) + when "to_array" then func_to_array(eval(args, 0, current)) + when "to_number" then func_to_number(eval(args, 0, current)) + when "to_string" then func_to_string(eval(args, 0, current)) + when "type" then func_type(eval(args, 0, current)) + when "values" then func_values(eval(args, 0, current)) + else + raise UnknownFunctionError.new(name) + end + end + + # -- Argument helpers -- + + private def self.eval(args : Array(Node), index : Int32, current : JSON::Any) : JSON::Any + args[index].visit(current) + end + + private def self.expref(args : Array(Node), index : Int32) : Node + node = args[index] + if expref_node = node.as?(ExprefNode) + expref_node.expression + else + raise JMESPathTypeError.new("argument #{index}", jmespath_type(node.visit(JSON::Any.new(nil))), "expref") + end + end + + private def self.eval_all(args : Array(Node), current : JSON::Any) : Array(JSON::Any) + args.map { |arg| arg.visit(current) } + end + + private def self.validate_arity(name : String, actual : Int32) + min, max = ARITY[name] + if actual < min + raise ArityError.new(name, "at least #{min}", actual) + end + if max != -1 && actual > max + raise ArityError.new(name, "at most #{max}", actual) + end + end + + # -- Number functions -- + + private def self.func_abs(v : JSON::Any) : JSON::Any + case raw = v.raw + when Int64 then JSON::Any.new(raw.abs) + when Float64 then JSON::Any.new(raw.abs) + else raise_type_error("abs", v, "number") + end + end + + private def self.func_avg(v : JSON::Any) : JSON::Any + arr = require_array("avg", v) + return JSON::Any.new(nil) if arr.empty? + sum = arr.sum { |e| require_number("avg", e) } + JSON::Any.new(sum / arr.size) + end + + private def self.func_ceil(v : JSON::Any) : JSON::Any + case raw = v.raw + when Int64 then JSON::Any.new(raw) + when Float64 then JSON::Any.new(raw.ceil.to_i64) + else raise_type_error("ceil", v, "number") + end + end + + private def self.func_floor(v : JSON::Any) : JSON::Any + case raw = v.raw + when Int64 then JSON::Any.new(raw) + when Float64 then JSON::Any.new(raw.floor.to_i64) + else raise_type_error("floor", v, "number") + end + end + + private def self.func_sum(v : JSON::Any) : JSON::Any + arr = require_array("sum", v) + return JSON::Any.new(0_i64) if arr.empty? + if arr.all? { |e| e.raw.is_a?(Int64) } + JSON::Any.new(arr.sum { |e| e.as_i64 }) + else + JSON::Any.new(arr.sum { |e| require_number("sum", e) }) + end + end + + # -- String / array functions -- + + private def self.func_length(v : JSON::Any) : JSON::Any + case raw = v.raw + when String then JSON::Any.new(raw.size.to_i64) + when Array then JSON::Any.new(raw.size.to_i64) + when Hash then JSON::Any.new(raw.size.to_i64) + else raise_type_error("length", v, "string, array, or object") + end + end + + private def self.func_reverse(v : JSON::Any) : JSON::Any + case raw = v.raw + when String then JSON::Any.new(raw.reverse) + when Array then JSON::Any.new(raw.reverse) + else raise_type_error("reverse", v, "string or array") + end + end + + private def self.func_sort(v : JSON::Any) : JSON::Any + arr = require_array("sort", v) + return JSON::Any.new(arr) if arr.empty? + + if arr.all? { |e| e.raw.is_a?(String) } + JSON::Any.new(arr.sort_by { |e| e.as_s }) + elsif arr.all? { |e| e.raw.is_a?(Int64) || e.raw.is_a?(Float64) } + JSON::Any.new(arr.sort_by { |e| to_f64(e) }) + else + raise_type_error("sort", v, "array of strings or array of numbers") + end + end + + private def self.func_join(glue : JSON::Any, arr : JSON::Any) : JSON::Any + g = glue.as_s? || raise_type_error("join", glue, "string") + a = require_array("join", arr) + strings = a.map { |e| e.as_s? || raise_type_error("join", e, "string") } + JSON::Any.new(strings.join(g)) + end + + private def self.func_contains(subject : JSON::Any, search : JSON::Any) : JSON::Any + case raw = subject.raw + when String + search_str = search.as_s? + return JSON::Any.new(false) unless search_str + JSON::Any.new(raw.includes?(search_str)) + when Array + JSON::Any.new(raw.any? { |e| e.raw == search.raw }) + else + raise_type_error("contains", subject, "string or array") + end + end + + private def self.func_starts_with(str : JSON::Any, prefix : JSON::Any) : JSON::Any + s = str.as_s? || raise_type_error("starts_with", str, "string") + p = prefix.as_s? || raise_type_error("starts_with", prefix, "string") + JSON::Any.new(s.starts_with?(p)) + end + + private def self.func_ends_with(str : JSON::Any, suffix : JSON::Any) : JSON::Any + s = str.as_s? || raise_type_error("ends_with", str, "string") + sf = suffix.as_s? || raise_type_error("ends_with", suffix, "string") + JSON::Any.new(s.ends_with?(sf)) + end + + # -- Object functions -- + + private def self.func_keys(v : JSON::Any) : JSON::Any + hash = v.as_h? || raise_type_error("keys", v, "object") + JSON::Any.new(hash.keys.map { |k| JSON::Any.new(k) }) + end + + private def self.func_values(v : JSON::Any) : JSON::Any + hash = v.as_h? || raise_type_error("values", v, "object") + JSON::Any.new(hash.values) + end + + private def self.func_merge(args : Array(JSON::Any)) : JSON::Any + result = {} of String => JSON::Any + args.each do |arg| + hash = arg.as_h? || raise_type_error("merge", arg, "object") + result.merge!(hash) + end + JSON::Any.new(result) + end + + # -- Conversion functions -- + + private def self.func_type(v : JSON::Any) : JSON::Any + JSON::Any.new(jmespath_type(v)) + end + + private def self.func_to_array(v : JSON::Any) : JSON::Any + if v.as_a? + v + else + JSON::Any.new([v]) + end + end + + private def self.func_to_string(v : JSON::Any) : JSON::Any + if v.as_s? + v + else + JSON::Any.new(v.to_json) + end + end + + private def self.func_to_number(v : JSON::Any) : JSON::Any + case raw = v.raw + when Int64, Float64 + v + when String + begin + if raw.includes?(".") + JSON::Any.new(raw.to_f64) + else + JSON::Any.new(raw.to_i64) + end + rescue + JSON::Any.new(nil) + end + else + JSON::Any.new(nil) + end + end + + # -- Min / max functions -- + + private def self.func_max(v : JSON::Any) : JSON::Any + arr = require_array("max", v) + return JSON::Any.new(nil) if arr.empty? + + if arr.all? { |e| e.raw.is_a?(String) } + arr.max_by { |e| e.as_s } + elsif arr.all? { |e| e.raw.is_a?(Int64) || e.raw.is_a?(Float64) } + arr.max_by { |e| to_f64(e) } + else + raise_type_error("max", v, "array of strings or array of numbers") + end + end + + private def self.func_min(v : JSON::Any) : JSON::Any + arr = require_array("min", v) + return JSON::Any.new(nil) if arr.empty? + + if arr.all? { |e| e.raw.is_a?(String) } + arr.min_by { |e| e.as_s } + elsif arr.all? { |e| e.raw.is_a?(Int64) || e.raw.is_a?(Float64) } + arr.min_by { |e| to_f64(e) } + else + raise_type_error("min", v, "array of strings or array of numbers") + end + end + + private def self.func_not_null(args : Array(JSON::Any)) : JSON::Any + args.each do |arg| + return arg unless arg.raw.nil? + end + JSON::Any.new(nil) + end + + # -- Expref functions -- + + private def self.func_map(expr : Node, arr : JSON::Any) : JSON::Any + a = require_array("map", arr) + result = a.map { |element| expr.visit(element) } + JSON::Any.new(result) + end + + private def self.func_sort_by(arr : JSON::Any, expr : Node) : JSON::Any + a = require_array("sort_by", arr) + return JSON::Any.new(a) if a.empty? + + # Determine sort type from first element + first_key = expr.visit(a[0]) + if first_key.raw.is_a?(String) + JSON::Any.new(a.sort_by { |e| expr.visit(e).as_s }) + elsif first_key.raw.is_a?(Int64) || first_key.raw.is_a?(Float64) + JSON::Any.new(a.sort_by { |e| to_f64(expr.visit(e)) }) + else + raise_type_error("sort_by", first_key, "number or string") + end + end + + private def self.func_max_by(arr : JSON::Any, expr : Node) : JSON::Any + a = require_array("max_by", arr) + return JSON::Any.new(nil) if a.empty? + + first_key = expr.visit(a[0]) + if first_key.raw.is_a?(String) + a.max_by { |e| expr.visit(e).as_s } + elsif first_key.raw.is_a?(Int64) || first_key.raw.is_a?(Float64) + a.max_by { |e| to_f64(expr.visit(e)) } + else + raise_type_error("max_by", first_key, "number or string") + end + end + + private def self.func_min_by(arr : JSON::Any, expr : Node) : JSON::Any + a = require_array("min_by", arr) + return JSON::Any.new(nil) if a.empty? + + first_key = expr.visit(a[0]) + if first_key.raw.is_a?(String) + a.min_by { |e| expr.visit(e).as_s } + elsif first_key.raw.is_a?(Int64) || first_key.raw.is_a?(Float64) + a.min_by { |e| to_f64(expr.visit(e)) } + else + raise_type_error("min_by", first_key, "number or string") + end + end + + # -- Helpers -- + + private def self.to_f64(v : JSON::Any) : Float64 + case raw = v.raw + when Int64 then raw.to_f64 + when Float64 then raw + else 0.0 + end + end + + private def self.require_number(func : String, v : JSON::Any) : Float64 + case raw = v.raw + when Int64 then raw.to_f64 + when Float64 then raw + else raise_type_error(func, v, "number") + end + end + + private def self.require_array(func : String, v : JSON::Any) : Array(JSON::Any) + v.as_a? || raise_type_error(func, v, "array") + end + + private def self.jmespath_type(v : JSON::Any) : String + case v.raw + when Nil then "null" + when Bool then "boolean" + when Int64 then "number" + when Float64 then "number" + when String then "string" + when Array then "array" + when Hash then "object" + else "unknown" + end + end + + private def self.raise_type_error(func : String, v : JSON::Any, expected : String) : NoReturn + raise JMESPathTypeError.new(func, jmespath_type(v), expected) + end +end diff --git a/src/jmespath.cr b/src/jmespath.cr index b602f8a..dd39319 100644 --- a/src/jmespath.cr +++ b/src/jmespath.cr @@ -2,6 +2,7 @@ require "json" require "./lexer" require "./exceptions" require "./nodes" +require "./functions" require "./parser" require "./parsed_result" diff --git a/src/lexer.cr b/src/lexer.cr index 066e1b1..5ffa909 100644 --- a/src/lexer.cr +++ b/src/lexer.cr @@ -90,7 +90,7 @@ class Lexer elsif @current == '"' tokens << consume_quoted_identifier else - raise LexerError.new(@position, @current.to_s, "Unknown token #{@current}") + raise LexerError.new(@position, @current.to_s, "Unknown token '#{@current}'", @expression) end end tokens << Token::NULL_TOKEN unless tokens.empty? || tokens.last.type == "eof" @@ -149,7 +149,7 @@ class Lexer next_char end - raise LexerError.new(@position, str.to_s, "Unclosed literal for delimiter '#{delimiter}'") if @current != delimiter + raise LexerError.new(@position, str.to_s, "Unclosed literal for delimiter '#{delimiter}'", @expression) if @current != delimiter end next_char # Move past the closing delimiter @@ -173,10 +173,10 @@ class Lexer else parsed_value.to_s end - raise LexerError.new(@position, buffer, "Invalid JSON value type") unless value + raise LexerError.new(@position, buffer, "Invalid JSON value type", @expression) unless value Token.new("quoted_identifier", value, start, @position) rescue ex : JSON::ParseException - raise LexerError.new(@position, buffer, "Invalid JSON format: #{ex.message}") + raise LexerError.new(@position, buffer, "Invalid JSON format: #{ex.message}", @expression) end end @@ -227,7 +227,7 @@ class Lexer number_string = "-" + consume_number Token.new("number", number_string.to_i32, start, @position) else - raise LexerError.new(@position, @current.to_s, "Unknown token '-'") + raise LexerError.new(@position, @current.to_s, "Unknown token '-'", @expression) end end @@ -238,7 +238,7 @@ class Lexer Token.new("eq", "==", start, @position) else position = @current.nil? ? @position : @position - 1 - raise LexerError.new(position, "=", "Unknown token '='") + raise LexerError.new(position, "=", "Unknown token '='", @expression) end end end diff --git a/src/nodes.cr b/src/nodes.cr index dab00d1..2d12cd1 100644 --- a/src/nodes.cr +++ b/src/nodes.cr @@ -1,4 +1,5 @@ require "json" +require "./functions" # Base class for all AST nodes. Each node implements visit(value) # directly, using polymorphism instead of a central visitor dispatcher. @@ -526,7 +527,7 @@ class FunctionExpressionNode < Node end def visit(v : JSON::Any) : JSON::Any - raise NotImplementedError.new("Functions not yet implemented: #{@name}") + FunctionRuntime.invoke(@name, @args, v) end def type : String @@ -543,11 +544,15 @@ class FunctionExpressionNode < Node end class ExprefNode < Node + getter expression : Node + def initialize(@expression : Node) end def visit(v : JSON::Any) : JSON::Any - raise NotImplementedError.new("Expression references not yet implemented") + # Expression references are evaluated by FunctionRuntime, + # not visited directly. Return nil if used outside a function. + JSON::Any.new(nil) end def type : String diff --git a/src/parser.cr b/src/parser.cr index 87d78e0..4e2a48e 100644 --- a/src/parser.cr +++ b/src/parser.cr @@ -5,6 +5,7 @@ require "./parsed_result" class Parser @cache : Hash(String, ParsedResult) + @expression : String = "" BINDING_POWER = { "eof" => 0, @@ -61,6 +62,7 @@ class Parser end private def parse_expression(expression : String) : ParsedResult + @expression = expression lexer = Lexer.new @tokens = lexer.tokenize(expression) @index = 0 @@ -68,7 +70,7 @@ class Parser if @index < @tokens.size && current_token.type != "eof" t = current_token - raise ParseError.new(0, t.value.to_s, t.type, "Unexpected token: #{t.value}") + raise parse_error(t, "Unexpected token after expression: #{t.value}") end ParsedResult.new(expression, parsed) @@ -116,7 +118,7 @@ class Parser when "not" parse_not_expression else - raise ParseError.new(0, token.value.to_s, token.type, "Unexpected token: #{token.type}") + raise parse_error(token, "Unexpected token: #{token.type}") end end @@ -133,7 +135,7 @@ class Parser when "lparen" then parse_function_expression(left) when "filter" then parse_led_filter_projection(left) else - raise ParseError.new(0, token.value.to_s, token.type, "Unexpected left token: #{token.type}") + raise parse_error(token, "Unexpected token: #{token.type}") end end @@ -159,7 +161,7 @@ class Parser @index += 1 parse_multi_select_hash else - raise ParseError.new(0, current_token.value.to_s, current_token.type, "Invalid dot RHS") + raise parse_error(current_token, "Expected identifier, star, bracket, or brace after dot") end end @@ -205,8 +207,7 @@ class Parser right = parse_projection_rhs(BINDING_POWER["star"]) ProjectionNode.new(left, right) else - raise ParseError.new(0, current_token.value.to_s, - current_token.type, "Invalid bracket operation") + raise parse_error(current_token, "Expected number, colon, or star in bracket expression") end end @@ -222,8 +223,7 @@ class Parser parts[index] = current_token.value.as(Int32) @index += 1 else - raise ParseError.new(0, current_token.value.to_s, - current_token.type, "Invalid slice syntax") + raise parse_error(current_token, "Expected number or colon in slice expression") end end match("rbracket") @@ -269,19 +269,14 @@ class Parser advance # consume the 'dot' parse_dot_rhs(bp) else - raise ParseError.new( - 0, - current_token.value.to_s, - current_token.type, - "syntax error in projection" - ) + raise parse_error(current_token, "Expected dot, bracket, or filter after projection") end end private def match(expected_types : String | Array(String)) expected = expected_types.is_a?(String) ? [expected_types] : expected_types unless expected.includes?(current_token.type) - raise ParseError.new(0, current_token.value.to_s, current_token.type, + raise parse_error(current_token, "Expected #{expected.join(" or ")}, got #{current_token.type}") end @index += 1 if current_token.type != "eof" @@ -305,13 +300,7 @@ class Parser field_node = FieldNode.new(token.value.to_s) if current_token.type == "lparen" - t = current_token - raise ParseError.new( - 0, - t.value.to_s, - t.type, - "Quoted identifier not allowed for function names." - ) + raise parse_error(token, "Quoted identifier not allowed for function names.") end field_node @@ -395,12 +384,8 @@ class Parser private def parse_function_expression(left : Node) : Node unless left.type == "field" prev_token = @tokens[@index - 2]? - raise ParseError.new( - 0, - prev_token.try(&.value).to_s, - prev_token.try(&.type).to_s, - "Invalid function name '#{prev_token.try(&.value)}'" - ) + raise parse_error(prev_token || current_token, + "Invalid function name '#{prev_token.try(&.value)}'") end name = left.value.to_s @@ -416,4 +401,17 @@ class Parser FunctionExpressionNode.new(name, args) end + + # Extracts the start position from a token as Int32 + private def token_position(token : Token) : Int32 + case pos = token.start + when Int32 then pos + else 0 + end + end + + # Creates a ParseError with the correct position and expression context + private def parse_error(token : Token, msg : String) : ParseError + ParseError.new(token_position(token), token.value.to_s, token.type, msg, @expression) + end end From 0bd50d5c1cb5999eca7a675450c87522246da30d Mon Sep 17 00:00:00 2001 From: Alvaro Frias Garay Date: Sun, 29 Mar 2026 18:19:45 -0300 Subject: [PATCH 2/4] update ci job Signed-off-by: Alvaro Frias Garay --- .github/workflows/ci.yml | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb30d4c..7c091a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,6 @@ on: jobs: test: runs-on: ubuntu-latest - steps: - uses: actions/checkout@v3 @@ -25,35 +24,58 @@ jobs: - name: Run tests run: crystal spec + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install Crystal + uses: crystal-lang/install-crystal@v1 + with: + crystal: latest + - name: Check formatting run: crystal tool format --check + static-analysis: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install Crystal + uses: crystal-lang/install-crystal@v1 + with: + crystal: latest + + - name: Install dependencies + run: shards install + - name: Run static analysis run: crystal build --no-codegen src/jmespath.cr release: - needs: test + needs: [test, format, static-analysis] runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/v') permissions: contents: write - + steps: - uses: actions/checkout@v3 - + - name: Install Crystal uses: crystal-lang/install-crystal@v1 with: crystal: latest - + - name: Install dependencies run: shards install - + - name: Build release run: | crystal build --release --static src/jmespath.cr -o jmespath tar -czf jmespath-${GITHUB_REF#refs/tags/}.tar.gz jmespath - + - name: Create Release uses: softprops/action-gh-release@v1 with: From 6e9609584212e3bff4c580861e76357cc4ab8bd7 Mon Sep 17 00:00:00 2001 From: Alvaro Frias Garay Date: Sun, 29 Mar 2026 18:21:55 -0300 Subject: [PATCH 3/4] update files Signed-off-by: Alvaro Frias Garay --- README.md | 14 ++++++-------- shard.yml | 2 +- src/jmespath.cr | 2 +- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ce4a7e9..0cba194 100644 --- a/README.md +++ b/README.md @@ -54,17 +54,17 @@ The library supports most JMESPath expressions including: - Pipe expressions - Literal values - Comparisons and logical operators +- Expression references (`&expr`) for deferred evaluation +- 26 built-in functions: `abs`, `avg`, `ceil`, `contains`, `ends_with`, `floor`, `join`, `keys`, `length`, `map`, `max`, `max_by`, `merge`, `min`, `min_by`, `not_null`, `reverse`, `sort`, `sort_by`, `starts_with`, `sum`, `to_array`, `to_number`, `to_string`, `type`, `values` +- Informative error messages with expression context and caret pointing to the error position ## TODO The following features are still pending implementation: -1. Built-in Functions - - No built-in functions are currently implemented - - Need to add support for string, array, number manipulation functions +1. ~~Built-in Functions~~ (done in v0.3.0) -2. Expression References (expref) - - The `&` operator for function references is not implemented +2. ~~Expression References (expref)~~ (done in v0.3.0) 3. JMESPath Compliance - Need to implement comprehensive compliance test suite @@ -82,9 +82,7 @@ The following features are still pending implementation: - Optimize parser for large expressions - Add benchmarking suite -7. Error Handling Improvements - - More detailed error messages - - Better error recovery strategies +7. ~~Error Handling Improvements~~ (done in v0.3.0) ## Development diff --git a/shard.yml b/shard.yml index 4c86231..8c3904c 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: jmespath -version: 0.2.0 +version: 0.3.0 description: | A Crystal implementation of JMESPath - JSON Query Language diff --git a/src/jmespath.cr b/src/jmespath.cr index dd39319..9b8ceb9 100644 --- a/src/jmespath.cr +++ b/src/jmespath.cr @@ -7,7 +7,7 @@ require "./parser" require "./parsed_result" module JMESPath - VERSION = "0.2.0" + VERSION = "0.3.0" # Main entry point for JMESPath expressions # From 8972ae1b9eebe246a6b759a993ef7fe2b319a253 Mon Sep 17 00:00:00 2001 From: Alvaro Frias Garay Date: Sun, 29 Mar 2026 18:24:17 -0300 Subject: [PATCH 4/4] update readme Signed-off-by: Alvaro Frias Garay --- README.md | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0cba194..b527f40 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ JMESPath.search("people[*].age", data) # => [20, 25, 30] # Filters data = %({"people": [ - {"name": "bob", "age": 20}, + {"name": "bob", "age": 20}, {"name": "alice", "age": 25} ]}) JMESPath.search("people[?age > `20`].name", data) # => ["alice"] @@ -43,6 +43,52 @@ data = %({"foo": {"bar": "baz", "qux": "quux"}}) JMESPath.search("foo.{b: bar, q: qux}", data) # => {"b": "baz", "q": "quux"} ``` +### Built-in Functions + +```crystal +# String functions +JMESPath.search("length(foo)", %({"foo": "hello"})) # => 5 +JMESPath.search("starts_with(foo, 'hel')", %({"foo": "hello"})) # => true +JMESPath.search("join(', ', foo)", %({"foo": ["a", "b", "c"]})) # => "a, b, c" + +# Array functions +JMESPath.search("sort(foo)", %({"foo": [3, 1, 2]})) # => [1, 2, 3] +JMESPath.search("reverse(foo)", %({"foo": [1, 2, 3]})) # => [3, 2, 1] +JMESPath.search("contains(foo, `2`)", %({"foo": [1, 2, 3]})) # => true + +# Number functions +JMESPath.search("sum(foo)", %({"foo": [1, 2, 3]})) # => 6 +JMESPath.search("avg(foo)", %({"foo": [10, 20, 30]})) # => 20.0 +JMESPath.search("abs(foo)", %({"foo": -5})) # => 5 + +# Object functions +JMESPath.search("keys(foo)", %({"foo": {"a": 1, "b": 2}})) # => ["a", "b"] +JMESPath.search("values(foo)", %({"foo": {"a": 1, "b": 2}})) # => [1, 2] + +# Type conversion +JMESPath.search("to_string(foo)", %({"foo": 42})) # => "42" +JMESPath.search("to_number(foo)", %({"foo": "42"})) # => 42 +JMESPath.search("type(foo)", %({"foo": "hello"})) # => "string" +``` + +### Expression References (expref) + +The `&` operator creates a reference to an expression that is evaluated later by functions like `sort_by`, `max_by`, `min_by`, and `map`: + +```crystal +# Sort by a field +data = %({"people": [{"name": "bob", "age": 30}, {"name": "alice", "age": 25}]}) +JMESPath.search("sort_by(people, &age)[*].name", data) # => ["alice", "bob"] + +# Find max/min by expression +JMESPath.search("max_by(people, &age).name", data) # => "bob" +JMESPath.search("min_by(people, &age).name", data) # => "alice" + +# Map an expression over an array +JMESPath.search("map(&name, people)", data) # => ["bob", "alice"] +``` + + ## Features The library supports most JMESPath expressions including: