Repository navigation
Release v2.2.12 - "Gymkata"
Release v2.2.12
Changed
- The mimalloc allocator no longer falls into its aligned slow path. With the
mimalloc
feature (the published wheels), every allocation went throughmi_malloc_aligned. There,
once the free list of a size class whose block size is not a power of two runs dry, each
allocation of that size over-allocates from a larger class instead of refilling it (about
110 extra instructions per allocation), and which sizes are hit depends on what else
shares their class. The global allocator now callsmi_mallocfor the alignments mimalloc
guarantees (up to 16 bytes on 64-bit targets, 8 on 32-bit, for sizes that are a multiple
of the alignment), which is nearly every Rust allocation, and uses the aligned API for the
rest. Callgrind on the A/B benchmark expressions: 0.2% to 3.6% fewer instructions on every
one, both engines (Sort 3.6%, Deep Path 2.8%, Parent 1.9%). - An object constructor or
.(expr)path step is compiled once per evaluation, not each
time it runs (#197). The evaluator
keeps each step's compiled form, so a step inside a function body, such as$map(big, function($v){ $v.items.{"a": x, "b": y} }), compiles once instead of once per call (about
10% fewer instructions for 200 calls), and a step that does not compile is not retried.
Whether a builtin the step calls is shadowed is still checked every time it runs. Rust API:
PathStephas a newid: StepIdfield, the key for that cache (StepId::default()in a
struct literal, which gives a new id;PathStep::newandwith_stagesset it). It is
ignored by equality and serde, and code that rewrites a step'snodeshould give it a new
id.AstNodestaysSend + Sync. - A
$step over an array known to hold no arrays skips the nested-array scan
(#196).JArraygains a privateflat
flag, read withis_flat()and set withJArray::new_flatorJValue::sequence_flat, for
arrays known to hold no array a path step flattens. Input conversion (JSON,serde_json,
Python lists), numeric ranges, path steps that flattened nothing, field lookups over arrays
and[...]constructors over flat parts set it where knowing costs nothing;make_mutand
with_itemsclear it.$sum(values.$)over 1,000 numbers runs about 14% fewer instructions
(callgrind), and results are unchanged, includingn.$over nested arrays. - A filter, sort or group-by right after a sort of a tuple stream sees each tuple as an
object (#214). jsonata-js's sort returns
a tuple stream's tuples as a plain array, so the stage that follows reads the tuple, not its
value:acct.Order.P^(>%.id)[N = "C"]is undefined (it was{"N":"C"}), a second sort such
as^(N)keeps the order,{N: N}gives{}, ando.t^(>%.id)^($)is T2008. This holds
after a position filter such as[0]too. Later path steps see the values as before, and a
sort used as a value ($x := acct.Order.P^(>%.id),$string(...)) gives the values rather
than the internal tuples. - A group-by on a path groups the whole path
(#208). In jsonata-js the{...}is
the path's group, and later.steps, calls, filters, sorts and binds extend that same path
before it is grouped. Soacct.Order.P{N: N}.Agroupsacct.Order.P.Aand gives{}(it
was"A"),acct.Order.P{N: N}[0]filters stepP(it was S0209), and a second group-by
on the same path, as inacct.Order{id: id}.P{N: N}, is S0210. A group-by on a block,$x
or*still applies to that step alone, and[]after it keeps the array instead of
raising S0209. - Rust API:
JValue::Arrayholds aJArrayinstead of anRc<Vec<JValue>>(#184).JArray
dereferences toVec<JValue>, so code that reads arrays is unaffected. Code that builds them
callsJValue::array(vec), andJArray::make_mut,into_vecandptr_eqreplace
Rc::make_mut,Rc::try_unwrapandRc::ptr_eq.JArrayalso carries thesequence,cons
andkeep_singletonflags jsonata-js keeps on arrays, and anouter_wrapperflag for the
top-level input array. - Results are unwrapped and flattened by jsonata-js's two rules (#184). A path step's
results are flattened one level, except for arrays built by[...], and on the last step a
single array result is returned as is. After every expression, an empty sequence is
undefined and a one-item sequence is its item unless[]applies. These replace the
engine's own heuristics, so results now match jsonata-js 2.2.2 in cases such as
[{'a':[[1]]}].(a)([[1]]),n.$over nested arrays,$lookupand*(which flatten
one level),x[]@$v(now accepted) and a range that counts down ([3..1], undefined). - A top-level array input is one item to a path, as jsonata-js's
outerWrappermakes it,
while$,*and an object constructor see the bare array:{"x": a[0]}over
[{"a":[1,2]},{"a":[3]}]is{"x":[1,3]}.$eval(expr, array)wraps its focus the same way. - An object constructor groups an array input's items by key, as jsonata-js
evaluateGroupExpressiondoes: a value is evaluated over its group's items, so
[[1,2],[3]].{"x": $}is{"x":[1,2,3]}and over[4]it is{"x":4}. A group-by's keys
keep the order they are first produced in. functionandλare names, not keywords. They open a lambda only when immediately
followed by(, matching jsonata-js, which has no such keyword and decides lambda-ness from
the callee of a call. Anywhere else they are ordinary path steps, sofunctionnow reads a
field calledfunction(andλa field calledλ) instead of being a parse error.- A
/after a barefunction/λdivides instead of opening a regex literal. The lexer
treated the lambda opener as an operator for this purpose, but a lambda's next token is always
(— so a/can only follow one of these when it is a name, and a name is a value.
function / 2is now 21, as upstream. - A lambda's signature is validated when it is parsed, not when it is first called — but
only for the two errors jsonata-js also raises at parse time,S0401andS0402. Every other
signature complaint still surfaces at call time as before.
Fixed
-
Recursion through a tail-called partial application no longer crashes the process
(#255).
($f := function($n){ $n <= 0 ? 1 : $f($n-1, ?)() }; $f(2000))is 1, as in jsonata-js; it
overflowed the native stack at about 2,000 levels on both engines and killed the Python
process. A call of a value in tail position that is not a function with tail calls (a
partial application, also of a builtin such as$reduce(?, $g, 0), or a builtin called as
a value,($reduce)(xs, $g, 0)) was applied by the trampoline outside any evaluation
step, so a recursion through it bypassed both the native-stack guard and the depth limit.
It now runs under both: deep recursion evaluates, and with no base case it raises U1001 at
the 30,000-level limit. -
A tail-recursive loop no longer stops at 100,000 iterations
(#251). The tail-call trampoline had a
hardcoded 100,000-iteration cap when no timeout was set, so
($loop := function($n){ $n = 0 ? "done" : $loop($n - 1) }; $loop(200000))raised
"U1001: Stack overflow - maximum recursion depth (500) exceeded" on both engines, naming a
depth nothing enforced. jsonata-js has no such cap and returns"done". Tail calls now run
without any limit: a tail loop keeps one frame, so a million iterations take about 0.4s
with no memory growth.max_stack_depth(D1011) and the 30,000-level U1001 backstop still
bound non-tail recursion, and atimeout(D1012) is what stops a runaway tail loop. The
U1001 message now names its real limit and follows the wording of the jsonata-js test
runner's U1001: "Stack overflow error: evaluation nested deeper than 30000 levels. Check for
non-terminating recursive function. Consider rewriting as tail-recursive." -
$evalwith an undefined focus evaluates against the current input, as in jsonata-js
(#247).$eval("a", $v)with$v
unbound used the undefined focus as its input and was undefined; it now readsafrom
the input, as$eval("a")does. A defined focus still replaces the input. -
A builtin called in a function's tail position returns its result as jsonata-js does
(#250). jsonata-js runs such a call
from its trampoline, which never unwraps the result, but it was unwrapped here, so an
empty sequence became undefined:$map([1,2], function($v){ $keys({}) })was undefined
instead of[[], []], and$keys({"a":1})there gave"a"instead of["a"]. A direct
call such as$f()still unwraps it. With this,$keysof undefined and$lookupover
an array that matches nothing give an empty sequence, as in jsonata-js; a conditional
without an else in that position is undefined, not null; and$sorttests its
comparator's result with JavaScript truthiness, so a comparator returning[]or{}
swaps.$splitof undefined is undefined, not null, so
$map([1,2], function($v){ $split(nothing, " ") })is undefined instead of
[null, null]and[$split(nothing, " ")]is[];$sift(nothing, $f)is undefined
instead of an error. -
A builtin called in a function's tail position runs with the input jsonata-js gives it
(#247,
#250). jsonata-js's trampoline applies
it with the input of the function's own application: null when the function is a
callback of a builtin such as$mapor is applied by~>to a non-call, and the call
site's input for a direct call. It used the function body's input instead, so
$map(["a"], function($e){ $string() })gave the input instead of"null",
$map(["a"], function($e){ $eval("a") })gave 5 instead of undefined, and
($f := function(){ $eval("a") }; {"a":7}.$f())read the outer input instead of
{"a":7}. This affects only what such a builtin reads from the context:$eval's
focus, and a signature's-parameter left without an argument. A builtin that is
itself a function's tail call applies the functions it is given with the input they were
defined with, as jsonata-js's trampoline passes them unwrapped:
($o := function(){ $map([1], function(){ $eval("a") }) }; $o())is 5. Known gap
(#254): jsonata-js wraps a function
passed as an argument of a direct call in a closure that applies it with a null input,
which is not modelled yet, so
($h := function($f){ $f("x") }; $h(function($e){ $eval("a", $nope) }))gives 5 instead
of undefined. -
A string literal heading a path reads that field under its predicates, group-by, sort or
bindings."s"{k: v}.p,"s"[0].k,"s"#$i.kand"s"^(v).kread fields, as in
jsonata-js, whose.makes every string step of a path a name; only a bare"s".kdid, so
these read the string itself and gave undefined."s"{k: v}.%is the input (it was S0217). -
~>into a call of an expression's value passes the left side as the first argument.
"x" ~> $uppercase[0]()is"X"and1 ~> $o[1]()calls$o[1]with 1, as in
jsonata-js; only a call of a named function got the left side, so these called the function
without it (T0410) or applied its result (T2006). -
A called path naming a function without its
$raises T1005, as in jsonata-js:
uppercase#$i(),uppercase[0]()and($x := 1; x#$i())suggest$uppercaseand$x
instead of raising T1006. As upstream, the hint is given when the path gives nothing and
its first step names a builtin or a binding with a JavaScript-truthy value, and not when
that step is a quoted string after.(a."uppercase"[0]()is T1006). -
Calling a quoted string step after
.calls the string, raising T1006. In jsonata-js,
(takes"f"(or"f"[0],"f"#$i) as its procedure before.makes the path's string
steps names, so$o."f"("a")andarr."uppercase"()are T1006; they called the function in
fieldf, or raised T1005. -
Calling a step with an index or focus binding calls what the step gives.
($o := [function($x){$x}]; $o#$i(3))and$o.g[0]#$i(3)are 3, as in jsonata-js, where
#and@bind tighter than.and(takes the bound step as its procedure; they were
parse errors (T1006). A bound last step of a longer path is called in each context item.
$o[0]@$v(3)now raises the S0215 that$o[0]@$vdoes, instead of T1006. -
A filtered array constructor inside an array constructor keeps its nesting
(#249), as in jsonata-js, which pushes
a member that is an array constructor whole, with any predicates or[]after it.
[[0..9][$ % 2 = 0]]is[[0,2,4,6,8]]and[[1, 2, [3, 4]][-1]]is[[3,4]](they were
[0,2,4,6,8]and[3,4]), and so for[[1,2][]],[[[1,2]][0]],[1,[2,3][$>0]]and
a constructor in an object value;$count([[1,2][$>0]])is 1 (it was 2). A member that is
a path is still spliced in:[([1,2])[$>0]],[[1,2]#$i]and[[3,1,2]^($)]are flat. -
A filter on a step that gives nothing is still evaluated once, as in jsonata-js, whose
evaluateFilterfilters[undefined]there:x[$string(1,2,3)]raises T0410 and
x[[a()]]T1006 whenxis missing (they gave undefined). The result is still undefined,
and a filter that selects nothing (x[false],x[0]) stops the later ones. As there, a
sort step's filters do not run when the sort gives nothing (x^(a)[$error("e")]), nor do
those on the call after~>. A filter that calls nothing (x[$ > 5]) is not run on an
item without the field, as it cannot raise an error there. -
Calling a bound literal is the same T1006 as calling the literal.
"f"@$v(),
a.1@$v()anda.true#$i()raise T1006 while parsing, as"f"()anda.1()do, ahead of
a literal-step S0213 or another member's error ([$string(1,2,3), "f"@$v()]). A bound
.(expr)step is called as its block:a.(1)@$v()is T1006 (it had no error code). A@
on a sort or group-by under a unary minus after.comes after that.'s S0213
(true.-a^(x)@$vis S0213), as both bind looser than.in jsonata-js. -
A misplaced
@binding is reported before a literal path step, as in jsonata-js,
whoseprocessASTchecks a@step before the.around it checks its steps:
1[0]@$v.**.q,null[0]@$v.$append(a.c.$, n[])and1.a[0]@$vare S0215 and
1^(x)@$v.ais S0216 (they were S0213 since the S0213 check below covered every step). -
A literal used as a path step is S0213 wherever it is in the path
(#248), as in jsonata-js, which checks
every step of a path built by.. Only a literal right of a dot was rejected, sonull.$,
true.$,2#$q.a,null@$x.a,2[0].a,null^(x).aandnull.(1)were evaluated, and
a.-2gave[-2,-2](jsonata-js folds-2into a number first). A number followed by.
and no digit is now the number and a path step, as in jsonata-js's tokenizer, so2.aand
1.5.aare S0213 too (2.awas "Invalid number"), and an exponent with no digits (2e)
is S0201. The same check turns a string step into a field name anywhere in the path:
"a"[0].b,"a".(b),"a".[b]and"a"#$i.bread the fielda(they used the string). -
Creating a function with a deeply nested body no longer crashes deep in a recursion.
Creating a function clones its body and collects its free variables, two recursive walks
over the body that ran without the stack guard deep evaluation uses. A body nested a few
hundred levels deep, created inside a recursion thousands of calls deep, could overrun
the stack headroom left there and crash the process (a native stack overflow, seen on
Windows with a 300-level body). Both walks now move to a new stack segment when the
stack runs low, as the value walkers do. -
A null sort term raises T2008 whatever the term's form, as in jsonata-js. A term that is
not a plain field name (a.b,$.a, a function call) turned a null result into undefined,
so[{"a":{"b":2}},{"a":{"b":null}}]^(a.b)and[{"a":2},{"a":null}]^($.a)returned the
input unsorted; a plain field such as^(a)already raised T2008. The null is now kept, and
jsonata-js's comparator raises T2008 when it compares it with another defined term. A null
beside only undefined terms, or in a one-item sort, is never compared, so it does not raise
and sorts before the undefined ones:[{},{"a":null}]^($.a)is[{"a":null},{}](it was
[{},{"a":null}]). -
Calling an indexed variable calls the function it gives.
($o := [function(){1}]; $o[0]())is 1 and$string[0](1)is"1", as in jsonata-js, which evaluates a call's
procedure expression and calls the result; they were parse errors ("Invalid function name"),
and T1006 in v2.2.11, so$exists($o) ? $o[0]() : "none"failed to compile instead of giving
"none". An indexed field as a path's last step is called in each context item, as(binds
tighter than.in jsonata-js:$o.g[0]()gives whatg[0]returns (it was T1006). An
index that gives no function is still T1006 (T1008 for a partial application). -
A group-by on a leading array constructor followed by another step is undefined again,
as in jsonata-js and v2.2.11. jsonata-js groups on the constructor node and evaluates that
step once, so the next step reads the grouped object as an input with no items. Since the
path-group change for #208 the step ran
once per item instead:[1,2]{"k": $}.kgave[1,2]and[1,2]{$string($): $}.[nothing]
gave[]. These,[1,2]{"k": $}.{"z": $},[1,2][]{"k": $}.kand a group on a block
starting with a constructor (([1,2].$){"k": $}.k) are undefined.[1,2]{"k":$}is still
{"k":[1,2]},([1,2]){"k":$}.[5]is still[5]and[1,2]{"k":$}[0]is still S0209. -
Freeing a very deeply nested value no longer overflows the stack
(#233). A value was dropped by plain
recursion, one stack frame per level, so an array or object nested more than about 10,000
deep (easy to build with$reduce, e.g.$reduce([1..100000], function($a, $i){ {'a': $a} }, {})) crashed the process when it was freed. So did reading down a deep Python input
($reduce([1..99999], function($x, $i){ $x.a }, $)over{"a": {"a": ...}}), as each lazy
dict wrapper holds the next. The payloads behind an array's and an object'sRc, and lazy
dict wrappers, now check the stack before freeing values that hold containers, and once it
runs low they take the rest of the value apart on a heap work-list instead of recursing. A
small payload that frees no container (the common leaf object) skips the check after a
look at its values; a lazy Python dict wrapper records whether it holds a container, and
a chain of wrappers read down from one another checks once every 32 levels. Only what
this drop frees is taken apart; a shared value is just released. Callgrind on the A/B
benchmark expressions over Python input, against main with the allocator change above
(instructions per evaluation from the difference between 600 and 100 evaluations): at
most 0.72% more (the 5-level path lookup on the tree-walker, under 100 instructions),
0.5% to 0.65% more on the%,#,@and top-rated expressions, and fewer on about half.
Rust API:JValue::ObjectholdsRc<JMap>instead ofRc<IndexMap<String, JValue>>, and
JArray's items sit in aJItems.JMapdereferences to theIndexMap, andJItemsto
Vec<JValue>; both iterate by reference, andJMapcollects from(String, JValue)pairs.
JValue::objectstill takes anIndexMap. What breaks:JValue::Object(Rc::new(map))
needsRc::new(map.into())orJValue::object(map);Rc::try_unwrap,Rc::make_mutand
Rc::unwrap_or_cloneon an object's payload yield aJMap(use.into_inner(), or use it
through deref);LazyPyDict::to_object()returnsRc<JMap>. Not covered: closures. A
chain of tens of thousands of closures, each capturing the previous one, is still freed by
recursion. -
A function body resolves names where the function is defined, not where it is called
(#191). A binding in the caller's scope
leaked into the body:($f := function($x){ $string($x) }; ($string := function($x){"z"}; $f(1)))is"1"as in jsonata-js (it was"z"), and($g := function(){ $v }; $f := function($v){ $g() }; $f(1))is undefined (it was1). The same holds for transforms,
partial applications, tail calls, and internal temporaries such as$__temp_arg_0, and to a
closure returned from another function, which resolves through its maker's defining scope and
never its maker's callers. While the defining scope is still running, a binding made there
after the function is seen too; once that scope has finished, only the bindings the function
captured when it was defined remain (jsonata-js keeps the whole scope alive, so
($f := ($g := function(){ $y }; $y := 5; $g); $f())is 5 there and undefined here). A partial
application such as$uppercase(?)now resolves its function when it is made, as jsonata-js
does: rebinding$uppercaseafterwards no longer changes it, and partially applying an
unbound name is T1008 when the partial is made. Every compiled fast
path now checks builtin shadowing through one rule, so$map/$filter/$reducecallbacks,
object constructors, predicates and fused aggregates call a builtin only where the
tree-walker would; a parameter named like a builtin (function($string){ $string(1) }) now
shadows it there too. Calling a name bound to something that is not a function, such as
$string := 5; $string(1), is T1006 (T1008 when partially applied) instead of calling the
builtin. Deep recursion no longer slows down with depth when its body calls a builtin, since a
lookup skips the caller frames.$evalruns in its caller's scope without seeing the
evaluator's temporaries, and a binding it makes lands there, even when it is reached through
$map:($m := $map; $m(["$w := 3"], $eval); $w)is 3. A compositionf ~> gcalls g in
the scope it is called from and f in a call frame of its own below the scope where~>was,
as jsonata-js'schainASTdoes, so($x := 7; ($string ~> $eval)("$x"))is 7 (it was
undefined) and($c := $eval ~> $string; $c("$z := 5"); $z)stays undefined. g's call is a
tail call, so($inc ~> $loop)($n)in tail position loops without growing the stack.$eval
as a lambda body's tail call runs where the chain of tail calls began, as jsonata-js applies
a tail call's thunk:($v := "root"; $h := function(){ $eval("$v") }; ($v := "caller"; $h()))is "caller", while($r := $eval("$v"); $r)in the body still reads the body's own
scope. A tail-call loop reuses its frame for the next pass unless a function that can see
the frame outlives the pass; functions the pass makes and drops, including ones kept in
local arrays, objects or other functions, or tail-called, do not count, so such loops run in
constant memory. A function that does live on, such as one passed to the next pass, keeps
its frame, so a loop that passes a new closure on each pass still grows with the number of
passes. That is a cost of lexical scoping, as in jsonata-js: a loop that collects a closure
on each pass into an accumulator ($f($k - 1, $append($acc, $c))) keeps every pass's frame
with all its bindings, the accumulator so far included, so its memory grows with the square
of the number of passes (about 1.1 GB at 5,000 passes, where it was 41 MB). A builtin such as
$maprun as a tail call applies its function arguments where the chain of tail calls began,
as jsonata-js does ($sortexcepted, since jsonata-js fails that call with "comp is not a
function"); recursion through such a callback, or through a tail-called composition, goes as
deep as other recursion and is U1001 without a base case, never a native stack overflow.
Still different from jsonata-js: the right side of?:and??is not a
tail position here; a partial application of a user function does not carry the chain of
tail calls, and a$eval(?)partial runs where it is called, not where it was made; function
arguments of a builtin called outside a tail position are applied where the builtin runs, not
in jsonata-js's wrapper over the caller's environment; a tail$evaltakes the call
site's context as its input; and a composition's first function flattens one more level of
nested one-item sequences than jsonata-js when its result comes from such a tail-called
builtin (($r ~> $string)(4)with a$rnesting$mapis"[[0]]","[[[0]]]"there). -
A sort over one item gives that item, not a one-item array
(#221). jsonata-js sorts as a step after
its input, which flattens the input into a sequence, and returns a sequence of zero or one
item as is, so it is unwrapped:acct.Order[1].P^(N)is{"N":"C"}(it was[{"N":"C"}]),
[{"N":"C"}]^(N)likewise, and a sort over an empty array is undefined (it was[]). A[]
on the sorted path keeps the array (P[]^(N),P^(N)[],$#$i[]^($)); a[]on a block,
variable or constructor before the sort ($x[]^(p),$x[]#$i^(p)) does not. A sort whose
input is not a path (a block, literal, function call or array constructor) evaluates it once
per item of an array input, as a path's first step: over[{"a":[2,1]},{"a":[3]}],
(a[0])^($)is[2,3](it was2) and$count(a[0])^($)is2(it was1). A sort of
objects that only look like internal tuples ({"@": 2, "__tuple__": true}) now orders them as data instead of leaving
them unsorted. A group-by after a sort groups a single array as one item (z^($){"k": $}is
{"k": [5]}forz: [[5]]), an object constructor inside a.( … )block reads$as its
own context ([1].([[5]].{"v": $})is{"v": 5}), and a field sort term over an array item
reads its items' fields, so[[{"p":2},{"p":1}],[{"p":3}]]^(p)raises T2008 as in jsonata-js.
The order-by clause sorts with jsonata-js's merge sort and comparator: a missing term sorts
last in either direction ([{"p":1},{"q":1}]^(>p)keeps its order; it put{"q":1}first),
and is checked before the types, so[{"p":null},{"q":1}]^(p)keeps its order instead of
raising T2008. A leading array constructor with a focus or index binding is evaluated once,
the binding ignored, when a.follows it (jsonata-jsconsarray), so[$]@$w.($)over
[1,2,3]is[1,2,3]and[a]@$w.{"v": $}over[{"a":1},{"a":2}]is
{"v": [{"a":1},{"a":2}]}. -
A higher-order function evaluates its callback once, to a function value
(#204).$map,$filter,$reduce,
$single,$sift,$eachand$sortevaluate a callback that is not an inline lambda or a
variable once, then call the function it gives with as many arguments as it takes, as
jsonata-js'shofFuncArgsdoes. Before, the expression was evaluated again for each item
with the item as context, so$map([1,2], ($string))and$map([1,2], [$string][0])gave
functions instead of["1","2"],$filter([1,2], (function($v){$v>1}))was undefined
instead of 2, and a host function inside the callback ran once per item.$reduceraises
D3050 for any callback taking fewer than two arguments, so$reduce([1,2], $sum ~> $string)
and$reduce([1,2], $string)are D3050. The nine higher-order builtins have their
jsonata-js arities, so$map([[3,1]], $sort)hands$sorttwo arguments. -
A regex literal is a function value
(#205). As in jsonata-js,/re/called
with a string gives its first match as{match, start, end, groups, next}, withstartand
endin UTF-16 code units,groupsalways present andnext()giving the following match
(undefined after the last, D1004 for a zero-length one). It can be bound
(($f := /a/; $f("xa"))), passed as a callback ($filter(["ab","cd"], /b/)), composed on
either side of~>($sort ~> /a/), and passed to$match,$replace,$splitand
$containsfrom a variable.$type(/a/)is"function",$string(/a/)is"",
$boolean(/a/)is false, and a regex returned to Python isNone."xa" ~> /a/goes
through the general~>path, so its result hasgroupsandnext. A regex now reads as
one after:=,(,:,=,?and the other operators that were read as division
before ($string(/a/)was S0211),m.next()calls a field holding a function, and~>
binds at 40 as in jsonata-js, so"é" & "a" ~> /a/matches"éa". Deliberate
divergences: an undefined subject gives undefined (jsonata-js matches the text
"undefined"), and each match'snext()continues from where that match ended, while
jsonata-js's continues from wherever the regex last stopped, shared by every match it
gave:($m := /a/("xaya"); [$m.next().start, $m.next().start])is[3, 3]here and[3]
in jsonata-js. -
Non-tail recursion goes about 10,000 calls deep
(#200). Evaluation stopped at 302 levels
with U1001, so($f := function($n){ $n <= 0 ? 0 : 1 + $f($n - 1) }; $f(150))failed where
jsonata-js gives 150. jsonata-js has no fixed limit; here U1001 now comes at 30,000 levels,
about 10,000 calls, on both engines. Deep levels run on stack segments allocated as needed,
so this holds on a 1MB thread stack (Windows' default) too. Converting a deeply nested result
to and from Python, serializing it,$string,=and**likewise no longer overflow a
small stack.max_stack_depthstill raises D1011. A compiled sub-expression nested more than
128 levels deep now runs on the tree-walker. A Python host function called deep inside a
recursion gets a stack as large as its caller had, and on Python 3.14 CPython is told where
that stack is, so its own recursion still ends inRecursionErrorrather than a crash. -
$replacedrives a regex or a matcher function as jsonata-js does
(#198). A replacement function's
$m.startand$m.endcount UTF-16 code units, so$replace("éab", /a/, function($m) {$string($m.start)})is"é1b"(it was"é2b"). A zero-length match after the first is
D1004 with a replacement function too, so$replace("abc", /x*/, function($m){"-"})raises
it (it gave"-a-b-c-"), and is still found after the limit is reached. A first match may
be zero-length, so$replace("abc", /x*$/, "-")is"abc-"(it was D1004). A custom
matcher function works as the pattern, with a replacement string or function, and its
result is checked as$matchchecks it (T1010). -
$$nameis the variable named$name
(#201). jsonata-js reads everything after
the leading$as the variable name, so$$is the variable$(the input) and$$ais
the variable$a, undefined unless bound. Over{"a": [1, 2, 3]},$$aand$string($$a)
are undefined (they werenulland"null"),($a := 5; $$a)is undefined, and
($$x := 2; $$x * 3)is 6.$$and$$.aare unchanged. -
A lambda called as a path step gets only the arguments written
(#202). The parser no longer passes the
context item as an implicit first argument, so over{"a": [1, 2, 3]}
a.function($v) { $v * 2 }()is undefined (it was[2, 4, 6]), as in jsonata-js; pass
$to get the items. A parenthesised step called the same way, as in
a.(function($v) { $v * 2 })($), now works (it was T1006). -
Calling an expression applies any function value, as jsonata-js does.
($uppercase)("x")
is"X"anda.($string)()calls the builtin per item (both were errors). A?argument
partially applies it, so(function($v, $w) { $v + $w })(?, 1)(5)is 6, also as the last
expression of a lambda ($f := function() { $g(?) }was an error). Calling a
non-function, as in(1)(2), raises T1006 (it had no code). -
$may appear inside a name, as in jsonata-js:$a$bis the variablea$banda$b
the fielda$b(both were parse errors). -
@on a block,*or$outside a path binds nothing
(#213). jsonata-js only applies a
binding to a path step, so(acct.Order.P)@$vis the three products (it was the input,
three times),*@$v[0]is the first child and(acct.Order.P){N: N}@$vgroups the
products. A., sort or#after it still makes it a path.$[]#$idrops the[], as
the path#builds keeps no singleton array. -
A
%filter on a block or*outside a path raises S0217
(#211). jsonata-js reports
(acct.Order.P)[%.id = "o1"]and*[%.id = "o1"], which were accepted here. Inside a
path, as in(acct.Order.P)[%.id = "o1"].N, they still work. A%on either side of~>
is not passed on, as in jsonata-js, so%.id ~> $countis 0 where it raised S0217.
A filtered group-by under a sort, as in(acct.Order.P)[%.id = "o1"]{N: N}^(N), gives a
value, and@after a filtered group-by, as inacct.Order.P[0]{N: N}@$v, raises S0215. -
A sort over a block's tuple stream unwraps a single or empty result
(#207).(acct.Order.P[N="C"])^(%.id)
is{"N":"C"}(it was[{"N":"C"}]), and(acct.Order.P)[N = "Z"]^(>%.id)is undefined
(it was[]). -
A
%looks back through a group-by on a bare*or%
(#209).*{N: N}.%is the whole
input andacct.Order.P.(%{"x": id}).%reachesacct, as in jsonata-js, where both raised
S0217. Inside a block, as inacct.(*{"k": 1}).%oracct.(*).%, the%is undefined. -
A group-by over an empty tuple stream is
{}
(#215).acct.Order.P[%.id = "zz"]{"k": 1}
gave{"k": 1}. A group-by over an empty plain value still evaluates its pairs once, so
nope{"k": $count($)}stays{"k": 0}. -
%inside a group-by no longer raises S0217
(#192). As in jsonata-js, a%in a
group-by's key or value is never resolved and evaluates to undefined, so
acct.Order.P{%.id: N}is{}andacct.Order.P{N: $count(%)}counts 0. A%after a
group-by, as in(acct.Order.P{N: N}).%, seeks through the grouped path. S0217 now uses
jsonata-js's message: "The object representing the 'parent' cannot be derived from this
expression". -
~>follows jsonata-jsevaluateApplyExpression
(#193). The left side is evaluated once
and passed on even when undefined, sonope ~> $count()is0andnope ~> $append([])is
[](both used to be undefined). A call on the right gets the left side as its first
argument. Any other right side is evaluated and must be a function: two functions compose
wherever they appear, soarr ~> ($sum ~> $string)and$type($sum ~> $string)now work, and
5 ~> noperaises T2006 instead of giving undefined. A partial application is applied like
any function, so"abc" ~> $substring(?, ?)is"abc". The left side used to be evaluated
twice, calling a host function on it twice. Two jsonata-js behaviours are deliberately not
followed: it drops the filter on a call (arr ~> $map($f)[0]gives the whole array there),
and it matches a regex against an undefined left side as the text"undefined". -
A transform is always a function value that runs when invoked, as in jsonata-js. It used
to run at once wherever$was bound, so a~>into a transform inside another transform's
update, ora.(|x|{}|), ran it on the wrong input.$in a transform's update is now the
matched object, not the transform's input, and so it is in the delete clause, which is
evaluated for each matched object as jsonata-js does (it used to be evaluated once, against
the input). A delete clause that is not a string or an array of strings raises T2012, as
does one with a non-string item, which used to be skipped, and a transform called with more
than one argument raises T0410. Like a lambda, it closes over the variables it
uses, and written directly as a higher-order function's argument it is called, as a
composition such as$map(n, $sum ~> $string)now is. -
$sort,$singleand the other higher-order builtins can be called by value:
[3,1,2] ~> $sort,$f := $singleand$sort ~> $reverseused to raise T0410. They now run
as a direct call does, validating their arguments the same way. -
A host function named without a call is a function value, as a builtin is, so
5 ~> $dbl,$f := $dbl,$map(arr, $dbl)and$exists($dbl)work for a function
registered withregister.$existson a variable checks its binding first, so a bound
name that shadows a builtin, such as($sort := nope; $exists($sort)), isfalse. -
A lambda parameter without an argument is undefined, not
null, on every path:
($f := function($v) { $type($v) }; $f())used to be"null"when the body was simple
enough to compile, and a tail-recursive lambda bound it asnulltoo. -
$single, transforms and partial applications handle undefined as jsonata-js does
(#193).$singlegives undefined for an
undefined input and treatsnullas a one-item sequence. A transform gives undefined for an
undefined input and raises T0410 for one that is not an object or array, however it is
invoked ($t(5)used to return5). A placeholder left without an argument is undefined, not
null. -
??propagates errors and coalesces any undefined value, as jsonata-js does
(#195). jsonata-js readsa ?? bas
$exists(a) ? a : b.$error("x") ?? 1used to swallow the error and give1, and the right
side could then see variables left bound by the failed left side. An undefined left side that
isn't a path or a variable, such as$lookup({}, "a") ?? 3, used to give undefined instead of
3. -
Each evaluation runs in a scope of its own, as jsonata-js runs each in a new frame. An
Evaluatorreused through the Rust API used to keep what an earlier evaluation bound: a
top-level$x := 1, and on a failed evaluation any scope or temporary$an error skipped
popping. Bindings set on theContextbeforehand are still visible.$$is each evaluation's
own input; a reused evaluator used to keep answering it from the first. -
A
.(...)step's:=bindings stay in the step.($y := 1; a.($y := 5); $y)is1, as
in jsonata-js; it used to be5. In the Rust AST, a.(expr)step is now always
FunctionApplication(Block([...])), even with one expression, so it can be told apart from a
.$fn(...)step, whose:=arguments still bind outside the step. -
A builtin's arguments are all evaluated. Two leftover checks returned early: one for an
unbound variable argument, which skipped the other arguments' errors and signature checks and
gavenullrather than undefined ({"x": $string($nope)}is now{}); and one that
evaluated a path argument a first time, discarding any error, before evaluating it again. -
$matchreturns every match, as jsonata-js does (#194).
jsonata-js regexes are always global, so$match("aba", /a/)gives both matches, not only
the first, and the limit argument caps how many are returned. Each match object now always
hasgroups, and an unmatched capture group in it is undefined, not null.indexcounts
UTF-16 code units like JavaScript strings, not bytes, so it changes for text before the match
that isn't ASCII. A negative limit raisesD3040, and a regex that would match a zero-length
string again raisesD1004. A custom matcher's results are checked as jsonata-js checks them,
so a malformed result raisesT1010, and a limit of 0 no longer calls the matcher. -
A fractional limit in
$matchand$replacerounds up, as in jsonata-js, which keeps
matching while the count is below the limit.$replace("aaa", "a", "b", 1.5)is now"bba". -
Field paths in lambda bodies map over arrays.
($f := function() { objs.p }; $f())was
undefined whenobjsheld an array; it is now[1, 2]as in jsonata-js. -
Compiled predicates read the right field from objects with different key orders. A
predicate inside a lambda body, such asxs[a > 2].b, read fields by the first object's key
positions and so tested the wrong field on objects ordered differently. -
Index binding on a sort numbers the sorted items:
arr^($)#$iis the sorted array and
$iits positions, where it used to return the input object. -
Object keys that are null or repeated raise jsonata-js's errors: a null key is
T1003
(it used to be skipped), and two pairs with the same literal key areD1009(the later
value used to win). -
unknown(function)now reportsT1006("Attempted to invoke a non-function") instead of
S0202. Thefunctionkeyword in argument position used to commit the parser to a lambda,
which then failed on its shape before anything decided the call target was not a function.
(reference suiteerrors/case005, #150) -
A choice group containing a parameterized type —
<(sa<n>)>— now reportsS0402
("Choice groups containing parameterized types are not supported") instead ofS0202. The
signature parser rejected the shape while still reading characters, so it never got far enough
to say what it was looking at.S0401was carried as text inside an uncoded error and is now
a first-class variant too.
(reference suitefunction-signatures/case034,
#150)With these two, the jsonata-js reference suite is at 1686/1686 and
KNOWN_DIVERGENCESis empty.
Full commit list: v2.2.11...v2.2.12
Test Suite Compatibility
See the README for the current
reference-suite pass count -- this changelog no longer hardcodes a number here, since a
stale hardcoded count is exactly the kind of bug this project has previously shipped
and had to correct.
Installation
pip install jsonatapy==2.2.12# Cargo.toml
jsonata-core = "2.2.12"Verification
import jsonatapy
print(jsonatapy.__version__) # Should print: 2.2.12