Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions spec/joins_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
local jsonata = require("jsonata")
local parser = require("jsonata.parser")

describe("M6c parser: @ / # bind focus/index on the last flat step", function()
it("a@$x.b flattens to [a(focus x), b] with tuple set", function()
local ast = parser.parse("a@$x.b")
assert.are.equal("path", ast.type)
assert.are.equal("x", ast.steps[1].focus)
assert.is_true(ast.steps[1].tuple)
assert.are.equal("b", ast.steps[2].value)
assert.is_nil(ast.steps[2].focus)
end)

it("$#$pos wraps a single step with index set", function()
local ast = parser.parse("$#$pos")
assert.are.equal("path", ast.type)
assert.are.equal("pos", ast.steps[1].index)
assert.is_true(ast.steps[1].tuple)
end)

it("a.b@$l.c@$m keeps a flat 3-step path with two focuses", function()
local ast = parser.parse("a.b@$l.c@$m")
assert.are.equal(3, #ast.steps)
assert.are.equal("l", ast.steps[2].focus)
assert.are.equal("m", ast.steps[3].focus)
end)
end)

describe("M6c parser: validation errors", function()
it("@ with a non-variable rhs raises S0214 (token @)", function()
local ok, err = pcall(parser.parse, "Account.Order@o.Product")
assert.is_false(ok)
assert.are.equal("S0214", err.code)
assert.are.equal("@", err.token)
end)

it("# with a non-variable rhs raises S0214 (token #)", function()
local ok, err = pcall(parser.parse, "Account.Order@$o#i.Product")
assert.is_false(ok)
assert.are.equal("S0214", err.code)
assert.are.equal("#", err.token)
end)

it("@ after a predicate raises S0215", function()
local ok, err = pcall(parser.parse, "Account.Order[1]@$o.Product")
assert.is_false(ok)
assert.are.equal("S0215", err.code)
end)

it("@ after a sort raises S0216", function()
local ok, err = pcall(parser.parse, "Account.Order^(>OrderID)@$o.Product")
assert.is_false(ok)
assert.are.equal("S0216", err.code)
end)

it("# after a sort/filter does NOT raise (it indexes the step)", function()
assert.has_no.errors(function()
parser.parse("$^($)#$pos")
end)
assert.has_no.errors(function()
parser.parse("$[[1..4]]#$pos")
end)
end)
end)

local function run(src, input)
return jsonata.compile(src):evaluate(input)
end

describe("M6c eval: #$v index binding (0-based, natural order)", function()
local NUMS = { 3, 1, 4, 1, 5, 9 }

it("$#$pos[$pos<3] keeps the first three (0-based index)", function()
assert.are.same({ 3, 1, 4 }, run("$#$pos[$pos<3]", NUMS))
end)

it("$#$pos[$pos<3][1] then positionally indexes the survivors", function()
assert.are.equal(1, run("$#$pos[$pos<3][1]", NUMS))
end)

it("$#$pos[$pos<3]^($)[-1] sorts the survivors and takes the last", function()
assert.are.equal(4, run("$#$pos[$pos<3]^($)[-1]", NUMS))
end)

it("index carries through a following step (per input item, 0-based)", function()
local DATA = {
Account = {
Order = {
{ OrderID = "o1", Product = { { pid = 1 }, { pid = 2 } } },
{ OrderID = "o2", Product = { { pid = 3 } } },
},
},
}
local res = run("Account.Order#$o.Product.{ 'pid': pid, 'oi': $o }", DATA)
assert.are.same({
{ pid = 1, oi = 0 },
{ pid = 2, oi = 0 },
{ pid = 3, oi = 1 },
}, res)
end)
end)

describe("M6c eval: @$v focus binding (cross-product join)", function()
local DATA = {
order = { { oid = "A", pid = 1 }, { oid = "B", pid = 2 } },
product = {
{ pid = 1, name = "Hat" },
{ pid = 2, name = "Shoe" },
{ pid = 1, name = "Cap" },
},
}

it("order@$o.product@$p[$o.pid=$p.pid] joins on pid", function()
local res = run("order@$o.product@$p[$o.pid=$p.pid].{ 'order': $o.oid, 'name': $p.name }", DATA)
assert.are.same({
{ order = "A", name = "Hat" },
{ order = "A", name = "Cap" },
{ order = "B", name = "Shoe" },
}, res)
end)

it("focus does NOT advance @: product still evaluates from the root", function()
local res = run("order@$o.product@$p.{ 'o': $o.oid, 'p': $p.name }", DATA)
assert.are.equal(6, #res)
end)
end)

describe("M6c eval: deferred reorder cases don't crash (red is OK)", function()
it("$[[1..4]]#$pos[$pos>=2] evaluates to a structured value", function()
assert.has_no.errors(function()
run("$[[1..4]]#$pos[$pos>=2]", { 3, 1, 4, 1, 5, 9 })
end)
end)
it("$^($)#$pos[$pos<3] evaluates to a structured value", function()
assert.has_no.errors(function()
run("$^($)#$pos[$pos<3]", { 3, 1, 4, 1, 5, 9 })
end)
end)
end)

describe("M6c eval: % parent + plain paths still work (regression)", function()
it("a.b.%.c resolves the ancestor unchanged", function()
assert.are.equal(7, run("a.b.%.c", { a = { b = 1, c = 7 } }))
end)
end)
26 changes: 26 additions & 0 deletions spec/jsonata-suite/baseline.lua
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ return {
["comparison-operators/case024"] = true,
["comparison-operators/case025"] = true,
["comparison-operators/case026"] = true,
["comparison-operators/case027"] = true,
["comparison-operators/case028"] = true,
["comparison-operators/deep-equals/0"] = true,
["comparison-operators/deep-equals/1"] = true,
["comparison-operators/deep-equals/10"] = true,
Expand Down Expand Up @@ -818,10 +820,33 @@ return {
["inclusion-operator/case006"] = true,
["inclusion-operator/case007"] = true,
["inclusion-operator/case008"] = true,
["joins/employee-map-reduce/0"] = true,
["joins/employee-map-reduce/11"] = true,
["joins/errors/0"] = true,
["joins/errors/1"] = true,
["joins/errors/2"] = true,
["joins/errors/3"] = true,
["joins/index/0"] = true,
["joins/index/1"] = true,
["joins/index/10"] = true,
["joins/index/11"] = true,
["joins/index/13"] = true,
["joins/index/14"] = true,
["joins/index/2"] = true,
["joins/index/3"] = true,
["joins/index/4"] = true,
["joins/index/5"] = true,
["joins/index/7"] = true,
["joins/index/8"] = true,
["joins/index/9"] = true,
["joins/library-joins/0"] = true,
["joins/library-joins/1"] = true,
["joins/library-joins/2"] = true,
["joins/library-joins/3"] = true,
["joins/library-joins/4"] = true,
["joins/library-joins/5"] = true,
["joins/library-joins/6"] = true,
["joins/library-joins/9"] = true,
["lambdas/case000"] = true,
["lambdas/case001"] = true,
["lambdas/case002"] = true,
Expand Down Expand Up @@ -975,6 +1000,7 @@ return {
["partial-application/case003"] = true,
["partial-application/case004"] = true,
["performance/case000"] = true,
["performance/case001"] = true,
["predicates/case000"] = true,
["predicates/case001"] = true,
["predicates/case002"] = true,
Expand Down
22 changes: 22 additions & 0 deletions spec/operators_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,25 @@ describe("?? / ?: right-greedy RHS (matches jsonata expression(0))", function()
assert.are.equal(0, run('0 ?? "x" ? "b" : "c"'))
end)
end)

describe("comparison operator error codes (oracle fidelity)", function()
it("raises T2009 when operands are number/string of different types", function()
local ok1, err1 = pcall(run, '"a" < 3')
assert.is_false(ok1)
assert.are.equal("T2009", err1.code)

local ok2, err2 = pcall(run, '3 <= "a"')
assert.is_false(ok2)
assert.are.equal("T2009", err2.code)
end)

it("raises T2010 when an operand is not a number or string", function()
local ok1, err1 = pcall(run, "false > 1")
assert.is_false(ok1)
assert.are.equal("T2010", err1.code)

local ok2, err2 = pcall(run, 'null <= "x"')
assert.is_false(ok2)
assert.are.equal("T2010", err2.code)
end)
end)
4 changes: 4 additions & 0 deletions src/jsonata/errors.lua
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ local MESSAGES = {
S0201 = "Syntax error",
S0203 = "Expected token before end of expression",
S0211 = "The symbol cannot be used as a unary operator",
S0214 = "The right side of the {{token}} operator must be a variable name",
S0215 = "A context variable binding must precede any predicates on a step in a path expression",
S0216 = "A context variable binding must precede the order-by clause on a step in a path expression",
S0217 = "The object representing the 'parent' cannot be derived from this expression",
S0401 = "Type parameters can only be applied to functions and arrays",
S0402 = "Choice groups containing parameterized types are not supported",
Expand All @@ -24,6 +27,7 @@ local MESSAGES = {
T2004 = "The right side of the range operator (..) must evaluate to an integer",
T2007 = "Type mismatch when comparing values {{value}} and {{value2}} in order-by clause",
T2008 = "The expressions within an order-by clause must evaluate to numeric or string values",
T2009 = "The values {{value}} and {{value2}} either side of operator {{token}} must be of the same data type",
T2010 = "Operands of comparison must both be numbers or both be strings",
T2011 = "The insert/update clause of the transform expression must evaluate to an object",
T2012 = "The delete clause of the transform expression must evaluate to an array of strings",
Expand Down
70 changes: 64 additions & 6 deletions src/jsonata/evaluator.lua
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,26 @@ local function eval_binary(node, input, env)
elseif op == "!=" then
return not M.deep_equal(lhs, rhs)
elseif op == "<" or op == "<=" or op == ">" or op == ">=" then
-- Validate each DEFINED operand independently (jsonata skips undefined in
-- the type check); if either operand is undefined the result is undefined.
if not V.is_nothing(lhs) then
local lt = V.typeof(lhs)
if lt ~= "number" and lt ~= "string" then
errors.raise("T2010", { value = lhs })
end
end
if not V.is_nothing(rhs) then
local rt = V.typeof(rhs)
if rt ~= "number" and rt ~= "string" then
errors.raise("T2010", { value = rhs })
end
end
if V.is_nothing(lhs) or V.is_nothing(rhs) then
return V.NOTHING
end
local lt, rt = V.typeof(lhs), V.typeof(rhs)
if (lt ~= "number" and lt ~= "string") or lt ~= rt then
errors.raise("T2010", { value = lhs })
if lt ~= rt then
errors.raise("T2009", { value = lhs, value2 = rhs })
end
if op == "<" then
return lhs < rhs
Expand Down Expand Up @@ -526,11 +543,28 @@ local function finalize_sequence(seq, keep_singleton)
end
M.finalize_sequence = finalize_sequence

-- A path node is a tuple path when any of its steps carries .tuple. When such a
-- path appears as a nested step inside an enclosing tuple stream (e.g. a focus-
-- bound step that the parser wrapped in a path to attach a predicate), it must
-- yield its tuple stream so the enclosing loop can merge its bindings (its $v),
-- instead of collapsing to bare @ values.
local function path_is_tuple(node)
if node.type ~= "path" then
return false
end
for _, s in ipairs(node.steps) do
if s.tuple then
return true
end
end
return false
end

-- Tuple-stream variant of eval_path: used when any step carries .tuple (an
-- ancestor anchor wired by the parser). Tuples flow per item; a step with
-- .ancestor binds its INPUT item under the slot label on every output tuple;
-- sub-evaluations run with a per-tuple frame so `%` resolves via env lookup.
local function eval_path_tuple(node, input, env)
local function eval_path_tuple(node, input, env, want_tuples)
local steps = node.steps
local tuples
local start = 1
Expand All @@ -556,6 +590,16 @@ local function eval_path_tuple(node, input, env)
tuples[j][steps[1].ancestor.label] = input
end
end
if steps[1].index then
for j = 1, #tuples do
tuples[j][steps[1].index] = j - 1
end
end
if steps[1].focus then
for j = 1, #tuples do
tuples[j][steps[1].focus] = tuples[j]["@"]
end
end
if steps[1].predicate then
tuples = apply_predicates(tuples, steps[1].predicate, env, true)
end
Expand Down Expand Up @@ -588,7 +632,14 @@ local function eval_path_tuple(node, input, env)
local item = t["@"]
if not V.is_nothing(item) then
local frame = create_frame_from_tuple(env, t)
local res = eval_step_on_item(step, item, frame)
local res
if path_is_tuple(step) then
-- nested tuple path (e.g. focus/index step wrapped to hold a
-- predicate): evaluate it as a tuple stream so its bindings survive.
res = eval_path_tuple(step, item, frame, true)
else
res = eval_step_on_item(step, item, frame)
end
if not V.is_nothing(res) then
if V.is_sequence(res) and V.get_flag(res, "tuple_stream") then
-- nested tuple-returning path: merge its bindings wholesale
Expand All @@ -607,7 +658,14 @@ local function eval_path_tuple(node, input, env)
end
for b = 1, #list do
local nt = copy_tuple(t)
nt["@"] = list[b]
if step.focus then
nt[step.focus] = list[b] -- bind under $v; @ stays at the parent context
else
nt["@"] = list[b]
end
if step.index then
nt[step.index] = b - 1 -- 0-based position within this item's results
end
if step.ancestor then
nt[step.ancestor.label] = item
end
Expand All @@ -624,7 +682,7 @@ local function eval_path_tuple(node, input, env)
end
end

if node.tuple then
if node.tuple or want_tuples then
local seq = V.sequence()
for j = 1, #tuples do
seq[j] = tuples[j]
Expand Down
Loading
Loading