From ff6854f6eefaec6f0a02b6b9407ff73e42e3583a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Jan=20Fialka?= Date: Mon, 2 Mar 2026 12:34:25 +0100 Subject: [PATCH] [LIQ] Add `nulls first` / `nulls last` to `order by` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/space_lua/ast.ts | 1 + client/space_lua/eval.ts | 1 + client/space_lua/lua.grammar | 2 +- client/space_lua/parse-lua.js | 22 ++-- client/space_lua/parse-lua.terms.js | 32 ++--- client/space_lua/parse.ts | 22 +++- client/space_lua/query_collection.test.ts | 76 +++++++++++ client/space_lua/query_collection.ts | 14 +- client/space_lua/query_test.lua | 154 ++++++++++++++++++++++ website/Space Lua/Lua Integrated Query.md | 30 ++++- 10 files changed, 314 insertions(+), 40 deletions(-) diff --git a/client/space_lua/ast.ts b/client/space_lua/ast.ts index 49be9293..586f5cfe 100644 --- a/client/space_lua/ast.ts +++ b/client/space_lua/ast.ts @@ -322,6 +322,7 @@ export type LuaOrderBy = { type: "Order"; expression: LuaExpression; direction: "asc" | "desc"; + nulls?: "first" | "last"; } & ASTContext; export type LuaSelectClause = { diff --git a/client/space_lua/eval.ts b/client/space_lua/eval.ts index 4df11d9b..53a12105 100644 --- a/client/space_lua/eval.ts +++ b/client/space_lua/eval.ts @@ -894,6 +894,7 @@ export function evalExpression( query.orderBy = clause.orderBy.map((o) => ({ expr: o.expression, desc: o.direction === "desc", + nulls: o.nulls, })); break; } diff --git a/client/space_lua/lua.grammar b/client/space_lua/lua.grammar index 36d67a2d..0da004cd 100644 --- a/client/space_lua/lua.grammar +++ b/client/space_lua/lua.grammar @@ -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 { 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 } HavingClause { ckw<"having"> exp } diff --git a/client/space_lua/parse-lua.js b/client/space_lua/parse-lua.js index 57f9ce05..d214195c 100644 --- a/client/space_lua/parse-lua.js +++ b/client/space_lua/parse-lua.js @@ -1,22 +1,22 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. import {LRParser} from "@lezer/lr" -const spec_identifier = {__proto__:null,break:16, goto:20, do:24, end:26, while:30, nil:32, true:34, false:36, or:80, and:82, not:104, function:114, query:122, from:129, where:133, order:137, by:139, desc:143, select:147, limit:151, group:155, having:159, repeat:162, until:164, if:168, then:170, elseif:172, else:174, for:178, in:186, local:196, return:212} +const spec_identifier = {__proto__:null,break:16, goto:20, do:24, end:26, while:30, nil:32, true:34, false:36, or:80, and:82, not:104, function:114, query:122, from:129, where:133, order:137, by:139, desc:143, asc:145, nulls:147, first:149, last:151, select:155, limit:159, group:163, having:167, repeat:170, until:172, if:176, then:178, elseif:180, else:182, for:186, in:194, local:204, return:220} export const parser = LRParser.deserialize({ version: 14, - states: "F^O!ZQPOOOOQO'#Cc'#CcO!UQPO'#CaO!bQPOOOOQO'#FP'#FPO!vQQO'#CwO$]QPO'#FOOOQO'#FO'#FOO$gQPO'#FOOOQO'#Ed'#EdO%vQPO'#EcOOQO'#Ez'#EzOOQO'#Ek'#EkO%{QPO'#C_OOQO'#C_'#C_QOQPOOO!UQPO'#CeO&`QPO'#CgO!vQQO'#CjO&gQPO'#EOO!vQQO'#ERO!UQPO'#EWO!UQPO'#E_O&nQPO'#EeO&vQQO'#EiO'^QPO,58{OOQO'#Cq'#CqO!UQPO,59^O!vQQO,59`O(gQQO'#C|O(nQQO'#FUOOQO'#FQ'#FQOOQO,59f,59fO!UQPO,59fO(uQPO'#E|O,eQPO,59cOOQO'#Dc'#DcOOQO'#Dd'#DdOOQO'#De'#DeO!vQQO'#DaOOQO'#E|'#E|O,lQPO'#DfO,qQSO'#DjO,vQPO'#EtO-OQPO,5WQPO,5`QPO,5:zO!UQPO,5:zOOQO1G0e1G0eO!UQPO'#EhOOQO,5;R,5;RO!UQPO'#EuO>kQPO,5yAN>yO&`QPOAN>yO!>fQPO,5;]O!>mQPOAN>yO!>rQPO<|QSO<WQPO,5`QPO,5;OO!UQPO,5;OOOQO1G0i1G0iO!UQPO'#ElOOQO,5;V,5;VO!UQPO'#EyO>kQPO,5XQ`O'#FZOOQO1G0U1G0UOOQO-E8s-E8sOOQOAN>}AN>}O&`QPOAN>}O!>xQPO,5;aO!?PQPOAN>}O!?UQPO<R#Q;'S=o;'S;=`>}<%lO=ok>UVO!_>k!_!`?T!`#P>k#P#Q@X#Q;'S>k;'S;=`Ao<%lO>kk>nTO#P=o#P#Q7z#Q;'S=o;'S;=`>}<%lO=ok?QP;=`<%l=ok?WTO#P?g#P#Q@P#Q;'S?g;'S;=`?y<%lO?gk?jTO#P?g#P#Q7z#Q;'S?g;'S;=`?y<%lO?gk?|P;=`<%l?gk@UP#qk!_!`8Qk@[VO!_=o!_!`@q!`#P=o#P#QAu#Q;'S=o;'S;=`>}<%lO=ok@tTO#P=o#P#QAT#Q;'S=o;'S;=`>}<%lO=okAYV#qkO!_>k!_!`?T!`#P>k#P#Q@X#Q;'S>k;'S;=`Ao<%lO>kkArP;=`<%l>kkAxVO!_?g!_!`?T!`#P?g#P#QAu#Q;'S?g;'S;=`?y<%lO?goBdT$YSO#PBs#P#QCV#Q;'SBs;'S;=`Ci<%lOBskBvTO#PBs#P#QCV#Q;'SBs;'S;=`Ci<%lOBskCYTO#PBs#P#Q%b#Q;'SBs;'S;=`Ci<%lOBskClP;=`<%lBsoCtPjP#P#QCwnC|O$[n~DRO$V~~DWOq~~D]O#z~~DbOu~~DgP#|~!_!`$m", + tokenData: "Dj~RuXY#fYZ$Q[]#f]^$_pq#fqr$grs$rst)`uv)evw)jwx)oxy.Wyz.]z{.b{|.g|}.l}!O.s!O!P/g!P!Q/|!Q!R0^!R![1u![!]3m!]!^3z!^!_4R!_!`4e!`!a4m!c!}5P!}#O5b#O#P#w#P#QCo#Q#RC|#R#S5P#T#o5P#o#pDR#p#qDW#q#rD]#r#sDb~#kS#q~XY#f[]#fpq#f#O#P#w~#zQYZ#f]^#f~$VP#p~]^$Y~$_O#p~~$dP#p~YZ$Ym$jP!_!`$mm$rOzmk$uXOY$rZ]$r^r$rrs%bs#O$r#O#P%g#P;'S$r;'S;=`'}<%lO$rk%gO#ukk%j]rs$rwx$r!Q![&c#O#P$r#T#U$r#U#V$r#Y#Z$r#b#c$r#f#g$r#h#i$r#i#j(T#l#m(v#n#o$rk&fZOY$rZ]$r^r$rrs%bs!Q$r!Q!['X![#O$r#O#P%g#P;'S$r;'S;=`'}<%lO$rk'[ZOY$rZ]$r^r$rrs%bs!Q$r!Q![$r![#O$r#O#P%g#P;'S$r;'S;=`'}<%lO$rk(QP;=`<%l$rk(WP#o#p(Zk(^R!Q![(g!c!i(g#T#Z(gk(jS!Q![(g!c!i(g#T#Z(g#q#r$rk(yR!Q![)S!c!i)S#T#Z)Sk)VR!Q![$r!c!i$r#T#Z$r~)eO$[~~)jO$X~~)oO$P~k)rXOY)oZ])o^w)owx%bx#O)o#O#P*_#P;'S)o;'S;=`,u<%lO)ok*b]rs)owx)o!Q![+Z#O#P)o#T#U)o#U#V)o#Y#Z)o#b#c)o#f#g)o#h#i)o#i#j,{#l#m-n#n#o)ok+^ZOY)oZ])o^w)owx%bx!Q)o!Q![,P![#O)o#O#P*_#P;'S)o;'S;=`,u<%lO)ok,SZOY)oZ])o^w)owx%bx!Q)o!Q![)o![#O)o#O#P*_#P;'S)o;'S;=`,u<%lO)ok,xP;=`<%l)ok-OP#o#p-Rk-UR!Q![-_!c!i-_#T#Z-_k-bS!Q![-_!c!i-_#T#Z-_#q#r)ok-qR!Q![-z!c!i-z#T#Z-zk-}R!Q![)o!c!i)o#T#Z)o~.]Ol~~.bOm~~.gO$V~~.lO$T~o.sOvc#{[~.xP$U~}!O.{~/QTP~OY.{Z].{^;'S.{;'S;=`/a<%lO.{~/dP;=`<%l.{o/lPgi!O!P/oo/tP!Pm!O!P/wQ/|OcQ~0RQ$W~!P!Q0X!_!`$m~0^O$Y~~0cUd~!O!P0u!Q![1u!g!h1Z!z!{2W#X#Y1Z#l#m2W~0xP!Q![0{~1QRd~!Q![0{!g!h1Z#X#Y1Z~1^R{|1g}!O1g!Q![1m~1jP!Q![1m~1rPd~!Q![1m~1zSd~!O!P0u!Q![1u!g!h1Z#X#Y1Z~2ZR!Q![2d!c!i2d#T#Z2d~2iUd~!O!P2{!Q![2d!c!i2d!r!s1Z#T#Z2d#d#e1Z~3OR!Q![3X!c!i3X#T#Z3X~3^Td~!Q![3X!c!i3X!r!s1Z#T#Z3X#d#e1Z~3rPo~![!]3u~3zOU~_4ROSR#{[o4YQ$dQzm!^!_4`!_!`$mm4eO$Rm~4jP#z~!_!`$mo4tQ$eQzm!_!`$m!`!a4zm5PO$Sm~5US#s~!Q![5P!c!}5P#R#S5P#T#o5Po5gQik!_!`5m!}#OB_k5pQ!_!`5v!}#O=ok5yP!}#O5|k6PTO#P5|#P#Q6`#Q;'S5|;'S;=`8W<%lO5|k6cVO!_6x!_!`:s!`#P6x#P#Q;w#Q;'S6x;'S;=`=i<%lO6xk6{VO!_7b!_!`8^!`#P7b#P#Q8v#Q;'S7b;'S;=`=c<%lO7bk7eTO#P5|#P#Q7t#Q;'S5|;'S;=`8W<%lO5|k7wP!_!`7zk7}P!_!`8Qk8TP#P#Q%bk8ZP;=`<%l5|k8aTO#P8^#P#Q7t#Q;'S8^;'S;=`8p<%lO8^k8sP;=`<%l8^k8yUO!_5|!_!`9]!`#P5|#Q;'S5|;'S;=`8W<%lO5|k9`VO!_5|!_!`9u!`#P5|#P#Q6`#Q;'S5|;'S;=`8W<%lO5|k9xTO#P5|#P#Q:X#Q;'S5|;'S;=`8W<%lO5|k:^V#ukO!_6x!_!`:s!`#P6x#P#Q;w#Q;'S6x;'S;=`=i<%lO6xk:vVO!_8^!_!`;]!`#P8^#P#Q7t#Q;'S8^;'S;=`8p<%lO8^k;`TO#P8^#P#Q;o#Q;'S8^;'S;=`8p<%lO8^k;tP#uk!_!`7zk;zVO!_7b!_!`:s!`#P7b#P#QR#Q;'S=o;'S;=`>}<%lO=ok>UVO!_>k!_!`?T!`#P>k#P#Q@X#Q;'S>k;'S;=`Ao<%lO>kk>nTO#P=o#P#Q7z#Q;'S=o;'S;=`>}<%lO=ok?QP;=`<%l=ok?WTO#P?g#P#Q@P#Q;'S?g;'S;=`?y<%lO?gk?jTO#P?g#P#Q7z#Q;'S?g;'S;=`?y<%lO?gk?|P;=`<%l?gk@UP#uk!_!`8Qk@[VO!_=o!_!`@q!`#P=o#P#QAu#Q;'S=o;'S;=`>}<%lO=ok@tTO#P=o#P#QAT#Q;'S=o;'S;=`>}<%lO=okAYV#ukO!_>k!_!`?T!`#P>k#P#Q@X#Q;'S>k;'S;=`Ao<%lO>kkArP;=`<%l>kkAxVO!_?g!_!`?T!`#P?g#P#QAu#Q;'S?g;'S;=`?y<%lO?goBdT$^SO#PBs#P#QCV#Q;'SBs;'S;=`Ci<%lOBskBvTO#PBs#P#QCV#Q;'SBs;'S;=`Ci<%lOBskCYTO#PBs#P#Q%b#Q;'SBs;'S;=`Ci<%lOBskClP;=`<%lBsoCtPjP#P#QCwnC|O$`n~DRO$Z~~DWOq~~D]O$O~~DbOu~~DgP$Q~!_!`$m", tokenizers: [0, 1, 2, 3, 4], topRules: {"Chunk":[0,2]}, - dynamicPrecedences: {"132":1}, - specialized: [{term: 123, get: (value) => spec_identifier[value] || -1}], - tokenPrec: 4041 + dynamicPrecedences: {"136":1}, + specialized: [{term: 127, get: (value) => spec_identifier[value] || -1}], + tokenPrec: 4137 }) diff --git a/client/space_lua/parse-lua.terms.js b/client/space_lua/parse-lua.terms.js index ade51d75..df3ffa53 100644 --- a/client/space_lua/parse-lua.terms.js +++ b/client/space_lua/parse-lua.terms.js @@ -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 diff --git a/client/space_lua/parse.ts b/client/space_lua/parse.ts index 723d87fd..415c4b5a 100644 --- a/client/space_lua/parse.ts +++ b/client/space_lua/parse.ts @@ -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 { diff --git a/client/space_lua/query_collection.test.ts b/client/space_lua/query_collection.test.ts index 098b2db6..b46d12ee 100644 --- a/client/space_lua/query_collection.test.ts +++ b/client/space_lua/query_collection.test.ts @@ -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"); +}); diff --git a/client/space_lua/query_collection.ts b/client/space_lua/query_collection.ts index 53def980..855987de 100644 --- a/client/space_lua/query_collection.ts +++ b/client/space_lua/query_collection.ts @@ -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 { - 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" && diff --git a/client/space_lua/query_test.lua b/client/space_lua/query_test.lua index 3266d43d..0c998389 100644 --- a/client/space_lua/query_test.lua +++ b/client/space_lua/query_test.lua @@ -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 diff --git a/website/Space Lua/Lua Integrated Query.md b/website/Space Lua/Lua Integrated Query.md index eac79673..7b439dbf 100644 --- a/website/Space Lua/Lua Integrated Query.md +++ b/website/Space Lua/Lua Integrated Query.md @@ -11,7 +11,7 @@ General syntax: where group by [, , ...] having - order by + order by [asc | desc] [nulls { first | last }][, ...] limit , select ]] @@ -121,8 +121,12 @@ ${query[[ See [[Space Lua/Lua Integrated Query/Grouping]] for detailed examples. -## order by [desc] -The `order by` clause allows you to sort data, when `desc` is specified it reverts the sort order. +## order by [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 [, ]