[LIQ] Add nulls first / nulls last to order by

Extend the `order by` clause with optional `nulls first` and `nulls
last` modifiers following standard SQL semantics. Each sort key can
independently specify where `nil` values should appear in the result. By
default, ascending sorts place nulls last and descending sorts place
nulls first. An explicit nulls first or nulls last overrides this
default. The `asc` keyword is also now accepted as an explicit
alternative to the default ascending direction. Documentation and tests
were updated accordingly.

Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
This commit is contained in:
Matouš Jan Fialka
2026-03-02 12:34:25 +01:00
parent 8f193dc402
commit ff6854f6ee
10 changed files with 314 additions and 40 deletions
+1
View File
@@ -322,6 +322,7 @@ export type LuaOrderBy = {
type: "Order";
expression: LuaExpression;
direction: "asc" | "desc";
nulls?: "first" | "last";
} & ASTContext;
export type LuaSelectClause = {
+1
View File
@@ -894,6 +894,7 @@ export function evalExpression(
query.orderBy = clause.orderBy.map((o) => ({
expr: o.expression,
desc: o.direction === "desc",
nulls: o.nulls,
}));
break;
}
+1 -1
View File
@@ -104,7 +104,7 @@ FromClause { ckw<"from"> (Name "=")? exp }
WhereClause { ckw<"where"> exp }
LimitClause { ckw<"limit"> exp ("," exp)? }
OrderByClause { ckw<"order"> ckw<"by"> list<OrderBy> }
OrderBy { exp ckw<"desc">? }
OrderBy { exp (ckw<"desc"> | ckw<"asc">)? (ckw<"nulls"> (ckw<"first"> | ckw<"last">))? }
SelectClause { ckw<"select"> exp }
GroupByClause { ckw<"group"> ckw<"by"> list<exp> }
HavingClause { ckw<"having"> exp }
File diff suppressed because one or more lines are too long
+16 -16
View File
@@ -20,19 +20,19 @@ export const
WhereClause = 65,
OrderByClause = 67,
OrderBy = 70,
SelectClause = 72,
LimitClause = 74,
GroupByClause = 76,
HavingClause = 78,
IfStatement = 83,
ForStatement = 88,
ForNumeric = 90,
ForGeneric = 91,
NameList = 92,
ExpList = 94,
FuncName = 96,
VarList = 100,
AttNameList = 102,
AttName = 103,
Attrib = 104,
ReturnStatement = 105
SelectClause = 76,
LimitClause = 78,
GroupByClause = 80,
HavingClause = 82,
IfStatement = 87,
ForStatement = 92,
ForNumeric = 94,
ForGeneric = 95,
NameList = 96,
ExpList = 98,
FuncName = 100,
VarList = 104,
AttNameList = 106,
AttName = 107,
Attrib = 108,
ReturnStatement = 109
+17 -5
View File
@@ -35,7 +35,7 @@ const luaStyleTags = styleTags({
CompareOp: t.operator,
"true false": t.bool,
Comment: t.lineComment,
"return break goto do end while repeat until function local if then else elseif in for nil or and not query from where limit select order by desc group having":
"return break goto do end while repeat until function local if then else elseif in for nil or and not query from where limit select order by desc asc nulls first last group having":
t.keyword,
});
@@ -1258,12 +1258,24 @@ function parseQueryClause(t: ParseTree, ctx: ASTCtx): LuaQueryClause {
const orderBy: LuaOrderBy[] = [];
for (const child of t.children!) {
if (child.type === "OrderBy") {
orderBy.push({
const kids = child.children!;
let direction: "asc" | "desc" = "asc";
let nulls: "first" | "last" | undefined;
for (let i = 1; i < kids.length; i++) {
const typ = kids[i].type;
if (typ === "desc") direction = "desc";
else if (typ === "asc") direction = "asc";
else if (typ === "first") nulls = "first";
else if (typ === "last") nulls = "last";
}
const ob: LuaOrderBy = {
type: "Order",
expression: parseExpression(child.children![0], ctx),
direction: child.children![1]?.type === "desc" ? "desc" : "asc",
expression: parseExpression(kids[0], ctx),
direction,
ctx: context(child, ctx),
});
};
if (nulls) ob.nulls = nulls;
orderBy.push(ob);
}
}
return {
+76
View File
@@ -248,3 +248,79 @@ Deno.test("ArrayQueryCollection", async () => {
assertEquals(resultUpper[2].letter, "Z");
assertEquals(resultUpper[3].letter, "z");
});
Deno.test("ArrayQueryCollection - nulls ordering", async () => {
const rootEnv = new LuaEnv();
const collection = new ArrayQueryCollection([
{ name: "alice", priority: 10 },
{ name: "bob", priority: undefined },
{ name: "carol", priority: 50 },
{ name: "dave", priority: undefined },
{ name: "eve", priority: 1 },
]);
// Default: asc nulls last
const r1 = await collection.query(
{
objectVariable: "p",
orderBy: [{ expr: parseExpressionString("p.priority"), desc: false }],
},
rootEnv,
LuaStackFrame.lostFrame,
{},
);
assertEquals(r1[0].name, "eve");
assertEquals(r1[1].name, "alice");
assertEquals(r1[2].name, "carol");
assertEquals(r1[3].priority, undefined);
assertEquals(r1[4].priority, undefined);
// Default: desc nulls first
const r2 = await collection.query(
{
objectVariable: "p",
orderBy: [{ expr: parseExpressionString("p.priority"), desc: true }],
},
rootEnv,
LuaStackFrame.lostFrame,
{},
);
assertEquals(r2[0].priority, undefined);
assertEquals(r2[1].priority, undefined);
assertEquals(r2[2].name, "carol");
assertEquals(r2[3].name, "alice");
assertEquals(r2[4].name, "eve");
// Explicit: desc nulls last
const r3 = await collection.query(
{
objectVariable: "p",
orderBy: [{ expr: parseExpressionString("p.priority"), desc: true, nulls: "last" }],
},
rootEnv,
LuaStackFrame.lostFrame,
{},
);
assertEquals(r3[0].name, "carol");
assertEquals(r3[1].name, "alice");
assertEquals(r3[2].name, "eve");
assertEquals(r3[3].priority, undefined);
assertEquals(r3[4].priority, undefined);
// Explicit: asc nulls first
const r4 = await collection.query(
{
objectVariable: "p",
orderBy: [{ expr: parseExpressionString("p.priority"), desc: false, nulls: "first" }],
},
rootEnv,
LuaStackFrame.lostFrame,
{},
);
assertEquals(r4[0].priority, undefined);
assertEquals(r4[1].priority, undefined);
assertEquals(r4[2].name, "eve");
assertEquals(r4[3].name, "alice");
assertEquals(r4[4].name, "carol");
});
+10 -4
View File
@@ -131,6 +131,7 @@ export { buildItemEnvLocal as buildItemEnv };
export type LuaOrderBy = {
expr: LuaExpression;
desc: boolean;
nulls?: "first" | "last";
};
/**
@@ -440,7 +441,7 @@ async function orderByCompare(
aSelectRow?: any,
bSelectRow?: any,
): Promise<number> {
for (const { expr, desc } of orderBy) {
for (const { expr, desc, nulls } of orderBy) {
const aEnv = mkEnv(objectVariable, a, env, sf);
const bEnv = mkEnv(objectVariable, b, env, sf);
if (aSelectRow) {
@@ -479,11 +480,16 @@ async function orderByCompare(
aVal = await evalExpression(expr, aEnv, sf);
bVal = await evalExpression(expr, bEnv, sf);
}
// Handle nulls positioning
const aIsNull = aVal === null || aVal === undefined;
const bIsNull = bVal === null || bVal === undefined;
if (aIsNull && bIsNull) continue;
if (aIsNull) return desc ? -1 : 1;
if (bIsNull) return desc ? 1 : -1;
if (aIsNull || bIsNull) {
if (aIsNull && bIsNull) continue;
// Default: nulls last for asc, nulls first for desc
const nullsLast = nulls === "last" || (nulls === undefined && !desc);
if (aIsNull) return nullsLast ? 1 : -1;
return nullsLast ? -1 : 1;
}
if (
collation?.enabled &&
typeof aVal === "string" &&
+154
View File
@@ -1568,3 +1568,157 @@ do
assertEquals(r[1].sm, 60)
assertEquals(r[1].ct, 3)
end
-- 40. Order by with nulls — default behavior
do
local data = {
{ name = "alice", priority = 10 },
{ name = "bob" },
{ name = "carol", priority = 50 },
{ name = "dave" },
{ name = "eve", priority = 1 },
}
-- 40a. asc default: nulls last
local r1 = query [[
from
p = data
select { name = p.name }
order by
p.priority
]]
assertEquals(r1[1].name, "eve")
assertEquals(r1[2].name, "alice")
assertEquals(r1[3].name, "carol")
-- nulls at end (bob and dave, order between them is unspecified)
assertTrue(r1[4].name == "bob" or r1[4].name == "dave", "expected null-priority item")
assertTrue(r1[5].name == "bob" or r1[5].name == "dave", "expected null-priority item")
-- 40b. desc default: nulls first
local r2 = query [[
from
p = data
select { name = p.name }
order by
p.priority desc
]]
assertTrue(r2[1].name == "bob" or r2[1].name == "dave", "expected null-priority item")
assertTrue(r2[2].name == "bob" or r2[2].name == "dave", "expected null-priority item")
assertEquals(r2[3].name, "carol")
assertEquals(r2[4].name, "alice")
assertEquals(r2[5].name, "eve")
end
-- 41. Order by with explicit nulls last / nulls first
do
local data = {
{ name = "alice", priority = 10 },
{ name = "bob" },
{ name = "carol", priority = 50 },
{ name = "dave" },
{ name = "eve", priority = 1 },
}
-- 41a. desc nulls last (override default)
local r3 = query [[
from
p = data
select { name = p.name }
order by
p.priority desc nulls last
]]
assertEquals(r3[1].name, "carol")
assertEquals(r3[2].name, "alice")
assertEquals(r3[3].name, "eve")
assertTrue(r3[4].name == "bob" or r3[4].name == "dave", "expected null-priority item")
assertTrue(r3[5].name == "bob" or r3[5].name == "dave", "expected null-priority item")
-- 41b. asc nulls first (override default)
local r4 = query [[
from
p = data
select { name = p.name }
order by
p.priority asc nulls first
]]
assertTrue(r4[1].name == "bob" or r4[1].name == "dave", "expected null-priority item")
assertTrue(r4[2].name == "bob" or r4[2].name == "dave", "expected null-priority item")
assertEquals(r4[3].name, "eve")
assertEquals(r4[4].name, "alice")
assertEquals(r4[5].name, "carol")
end
-- 42. Order by nulls with unbound access
do
local data = {
{ name = "a", val = 3 },
{ name = "b" },
{ name = "c", val = 1 },
}
-- 42a. desc nulls last, unbound
local r = query [[
from
data
select { name = name }
order by
val desc nulls last
]]
assertEquals(r[1].name, "a")
assertEquals(r[2].name, "c")
assertEquals(r[3].name, "b")
-- 42b. asc nulls first, unbound
local r2 = query [[
from
data
select { name = name }
order by
val nulls first
]]
assertEquals(r2[1].name, "b")
assertEquals(r2[2].name, "c")
assertEquals(r2[3].name, "a")
end
-- 43. Order by nulls with multiple keys
do
local data = {
{ name = "a", x = 1, y = 10 },
{ name = "b", x = 1 },
{ name = "c", x = 2, y = 5 },
{ name = "d", x = 2 },
}
local r = query [[
from
p = data
select { name = p.name }
order by
p.x, p.y nulls first
]]
assertEquals(r[1].name, "b")
assertEquals(r[2].name, "a")
assertEquals(r[3].name, "d")
assertEquals(r[4].name, "c")
end
-- 44. Explicit asc keyword (same as default)
do
local r = query [[
from
p = pages
select { name = p.name }
order by
p.size asc
limit
2
]]
assertEquals(r[1].name, "Fran")
assertEquals(r[2].name, "Greg")
end
+27 -3
View File
@@ -11,7 +11,7 @@ General syntax:
where <expression>
group by <expression>[, <expression>, ...]
having <expression>
order by <expression>
order by <expression> [asc | desc] [nulls { first | last }][, ...]
limit <expression>, <expression>
select <expression>
]]
@@ -121,8 +121,12 @@ ${query[[
See [[Space Lua/Lua Integrated Query/Grouping]] for detailed examples.
## order by <expression> [desc]
The `order by` clause allows you to sort data, when `desc` is specified it reverts the sort order.
## order by <expression> [asc | desc] [nulls {first | last}][, ...]
The `order by` clause allows you to sort data. Use `desc` for descending order, or `asc` (the default) for ascending.
You can control where `nil` values are placed using `nulls first` or `nulls last`. The defaults follow SQL conventions:
- **`asc`**: nulls are placed **last**,
- **`desc`**: nulls are placed **first**.
As an example, the last 3 modified pages:
${query[[
@@ -141,6 +145,26 @@ ${query[[
limit 3
]]}
Override the default null placement with `nulls first` or `nulls last`:
```lua
query[[
from p = index.tag "page"
order by p.priority desc nulls last
select { name = p.name, priority = p.priority }
]]
```
Each sort key can have its own direction and nulls clause:
```lua
query[[
from p = data
order by p.category asc, p.priority desc nulls last
select { name = p.name }
]]
```
Sorting of strings can be adjusted with `queryCollation` in [[^Library/Std/Config]]
## limit <expression>[, <expression>]