Skip to main content

Module query

Module query 

Source
Expand description

The half of JSONPath that names more than one place.

Value::path answers one value and is the fast way to ask for one field, and it refuses [*] and .. on purpose because it has nowhere to put a second answer. This is the other half. $..price and $.items[*].sku and $.a[0:10:2] each name a set, and the JSON.* surface is written against sets rather than against single values: JSON.GET $..price on a document with four prices answers four numbers, and JSON.SET with the same path writes four times.

use yo_doc::{Path, Value, from_json};

let doc = from_json(br#"{"items":[{"sku":"a","price":3},{"sku":"b","price":5}]}"#)?;
let v = Value::new(&doc).expect("readable");

let mut hits = Vec::new();
Path::parse(b"$..price")?.select(&v, &mut hits);
let prices: Vec<i64> = hits.iter().filter_map(Value::as_int).collect();
assert_eq!(prices, [3, 5]);

§What is here

The root $, a child by name written either way, [*] and .*, the descent .., an index counting from either end, a union of indices or names in one bracket, and a slice with an optional step. That is RFC 9535 without its filter selector.

The filter, [?(@.price < 10)], is here too. It is the only selector whose answer depends on the document rather than only on the path, and the only one that can look somewhere other than where it stands, because $ inside an expression is the whole document.

use yo_doc::{Path, Value, from_json};

let doc = from_json(br#"{"items":[{"sku":"a","price":3},{"sku":"b","price":15}]}"#)?;
let v = Value::new(&doc).expect("readable");

let mut hits = Vec::new();
Path::parse(b"$.items[?(@.price < 10)].sku")?.select(&v, &mut hits);
let cheap: Vec<&str> = hits.iter().filter_map(Value::as_text).collect();
assert_eq!(cheap, ["a"]);

§What a filter’s operators mean

An operand is a path from the current node, a path from the root, or a literal, and a path answers a set rather than one value. A comparison is true when some pair drawn from the two sides satisfies it, so @.tags[*] == "x" asks whether any tag is x. A side that answers nothing satisfies nothing, which is why @.missing == 1 and @.missing < 1 are both false, and != is the negation of the whole comparison rather than a comparison of its own, which is why @.missing != 1 is true.

An ordering comparison only arises between two values of the same sort. Numbers order as numbers, strings order by their characters, false is below true, and two nulls are equal, so null >= null is true and null < null is not. Everything else is false: a string is not below a number, and an array or an object is not below anything at all, not even an equal one. Equality is the whole value, and it crosses the integer and float split so 1 == 1.0, but it crosses nothing else, so 0 == false and 1 == "1" are both false. All of that was read off RedisJSON 8.10.1 rather than off the RFC, which leaves most of it open.

An expression with no operator in it asks whether the operand is there. [?(@.price)] keeps the members that have a price, including the ones whose price is null or false, because it is a question about the document and not about the value. The one thing that is false on its own is the literal false, so [?(false)] keeps nothing while [?(0)] and [?(null)] keep everything.

=~ is a regular expression, and the flavour is the one in yo_common::re, which is what ARGREP uses. RedisJSON’s is the Rust regex crate’s, so the two agree on everything anyone writes by hand and part company on the corners, which is a row in the divergence register.

The parentheses everyone writes around a filter are not part of it, so [?@.a == 1] and [? (@.a == 1)] are the same filter. A filter iterates the children of an array and of an object alike, and it works under a write as well as a read: JSON.SET, JSON.DEL and JSON.NUMINCRBY all take one.

§The operators past the comparisons

in asks whether the left value is one of the elements of the array on the right, and nin is its negation over both whole sides. anyof and noneof ask whether two arrays share an element, and subsetof asks whether every element of the left array is on the right, which makes [] a subset of anything. size, which is also spelled sizeof, takes a bare number and is the length of a string, an array or an object, and empty takes true or false over the same three, so a number has neither and satisfies neither. The right hand side of all five may be a path rather than a literal, and it is the values that path answered that are the collection, so 3 in @.list and @.tags anyof $.wanted both read.

The postfix methods are .length(), .count(), .min(), .max(), .sum() and .avg(). count() is how many values the operand answered and is a number even when that number is zero, so @.nope.count() == 0 is true, and it is the only one of the six that answers for an operand that answered nothing. The four aggregates want an array of numbers and answer nothing for an empty one or for an array with anything else in it. A name that is not one of the six answers nothing rather than refusing the path, which is what @.p.size() does.

Arithmetic is + - * / % over numbers, * and / and % bind tighter than + and -, and parentheses group. Only * is an operator wherever it stands, so @.p*2 == 6 reads the way it looks. The other four are name characters and need their spaces: @.total-vat is a key called total-vat, @.a+1 is a key called a+1, and @.total - vat is the subtraction. The exception is straight after a ], where no name can be running, so @.list[0]-1 is a subtraction with no spaces in it at all. A leading - or + is a sign, it answers a number or nothing so -@.name on a string answers nothing, and one is as many as go in a row: --@.p is refused and -(-@.p) is how a second one is written.

Arithmetic and the methods want one node, which is the one place the set rule above does not hold. An operand that answered two answers nothing rather than a pair of sums, so @.list[*] + 1 on a list of two is nothing and @.list[*].length() is nothing as well. count() is outside that because counting is what it is for. This is worth knowing before writing a wildcard into an arithmetic operand, because what comes back is nothing rather than what it looks like it asks for.

The postfix ~ answers the key names of an object, one string each, and nothing at all for an array or a scalar. It is a set rather than an array value, so @.p~ == "x" is true of any object with an x in it, and the operators that want a collection read the whole set as one: @.p~ size 2 is an object with two keys, and @.p~ subsetof ["x","y"] is an object with no other key. It is a collection on the right of those operators too, so "x" in @.p~ asks whether the object has an x. in and =~ do not take it on the left and are false whatever is on the other side, which is the reference’s behaviour rather than a rule with a reason behind it.

An object with no keys answers a set that is there and empty, and something that is not an object answers no set at all, and every one of the collection operators tells the two apart. On {} the tests @.p~ subsetof ["x"], @.p~ empty true and @.p~ size 0 are all true, and on a number or a missing key none of the three is.

§A path that is an expression

JSON.GET, JSON.MGET and JSON.RESP take a path that is not a way through a document at all but a sum over one. $.a + $.b, $.list.length() and $.o~ are all projections, and what comes back is what the expression worked out rather than where it was found. Nothing else about them is different from the same expression inside a filter: the same operators, the same methods, the same one node rule. Path::is_projection tells the two apart and Path::project runs one.

A projection never fails the way a path does. $.nope + 1 answers [] rather than raising the “path does not exist” a legacy path would, and it answers an array even when it was written in the legacy syntax, so .a + 1 is [4] and not 4.

The first thing at the top level is a path however it is written, which is how .a + 1 and 2 + 3 both parse. The second is a member really called 2, plus three, and answers []. Inside parentheses the ordinary rules are back, so (2) is the number two. @ is refused up here, since there is no current node outside a filter.

The kind of number that comes out follows the reference and is not always the kind the arithmetic suggests. A whole number stays whole through +, -, * and %, / is always a fraction even when it divides evenly, length() and count() are whole, and min(), max(), sum() and avg() are fractions. Dividing or taking a remainder by zero answers nothing.

Every other JSON.* command refuses a projection rather than reading it as a path, so JSON.NUMINCRBY key "$.a + 1" 1 is an error and not a write somewhere nobody asked for.

§Two orderings that are not Redis’s

Matches come back in document order, and for an object that is key order, because that is the order members are stored in. RedisJSON walks an object in the order the client wrote it. This is the same difference the JSON writer has and it is the same one row in the register.

A descent walks a node before its children, which is what every JSONPath implementation does, so $..a on a document with an a inside an a answers the outer one first.

Structs§

Path
A parsed path.

Enums§

Computed
One value a projection worked out.