diff --git a/client/space_lua/aggregates.ts b/client/space_lua/aggregates.ts index 8311bc31..96b47198 100644 --- a/client/space_lua/aggregates.ts +++ b/client/space_lua/aggregates.ts @@ -16,6 +16,7 @@ import { luaCall, type LuaEnv, LuaTable, + luaTruthy, type LuaValue, } from "./runtime.ts"; import type { LuaExpression } from "./ast.ts"; @@ -157,8 +158,6 @@ export function getAggregateSpec(name: string): AggregateSpec | null { /** * Execute an aggregate function over a group of items. - * - * `evalExprFn` is passed in to avoid a circular import with eval.ts. */ export async function executeAggregate( spec: AggregateSpec, @@ -172,6 +171,7 @@ export async function executeAggregate( env: LuaEnv, sf: LuaStackFrame, ) => Promise | LuaValue, + filterExpr?: LuaExpression, ): Promise { const ctx = buildAggCtx(spec.name); @@ -182,6 +182,16 @@ export async function executeAggregate( const len = items.length; for (let i = 1; i <= len; i++) { const item = items.rawGet(i); + + // Filter + if (filterExpr) { + const filterEnv = buildItemEnv(objectVariable, item, env, sf); + const filterResult = await evalExprFn(filterExpr, filterEnv, sf); + if (!luaTruthy(filterResult)) { + continue; + } + } + let value: LuaValue; if (valueExpr === null) { value = item; diff --git a/client/space_lua/ast.ts b/client/space_lua/ast.ts index 586f5cfe..5c55bc41 100644 --- a/client/space_lua/ast.ts +++ b/client/space_lua/ast.ts @@ -175,7 +175,8 @@ export type LuaExpression = | LuaUnaryExpression | LuaTableConstructor | LuaFunctionDefinition - | LuaQueryExpression; + | LuaQueryExpression + | LuaFilteredCallExpression; export type LuaNilLiteral = { type: "Nil"; @@ -281,6 +282,13 @@ export type LuaFunctionDefinition = { body: LuaFunctionBody; } & ASTContext; +// Aggregate with per-row filter +export type LuaFilteredCallExpression = { + type: "FilteredCall"; + call: LuaFunctionCallExpression; + filter: LuaExpression; +} & ASTContext; + // Query stuff export type LuaQueryExpression = { type: "Query"; diff --git a/client/space_lua/lua.grammar b/client/space_lua/lua.grammar index 0da004cd..4ccb8a46 100644 --- a/client/space_lua/lua.grammar +++ b/client/space_lua/lua.grammar @@ -82,10 +82,10 @@ exp { UnaryExpression | TableConstructor | FunctionDef { kw<"function"> FuncBody } | - Query + Query | + FilteredCall { FunctionCall ckw<"filter"> "(" ckw<"where"> exp ")" } } - Query { kw<"query"> "[[" QueryClause* "]]" } diff --git a/client/space_lua/parse-lua.js b/client/space_lua/parse-lua.js index d214195c..0f70085a 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, 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} +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, filter:171, repeat:174, until:176, if:180, then:182, elseif:184, else:186, for:190, in:198, local:208, return:224} export const parser = LRParser.deserialize({ version: 14, - states: "FvO!ZQPOOOOQO'#Cc'#CcO!UQPO'#CaO!bQPOOOOQO'#FT'#FTO!vQQO'#CwO$]QPO'#FSOOQO'#FS'#FSO$gQPO'#FSOOQO'#Eh'#EhO%vQPO'#EgOOQO'#FO'#FOOOQO'#Eo'#EoO%{QPO'#C_OOQO'#C_'#C_QOQPOOO!UQPO'#CeO&`QPO'#CgO!vQQO'#CjO&gQPO'#ESO!vQQO'#EVO!UQPO'#E[O!UQPO'#EcO&nQPO'#EiO&vQQO'#EmO'^QPO,58{OOQO'#Cq'#CqO!UQPO,59^O!vQQO,59`O(gQQO'#C|O(nQQO'#FYOOQO'#FU'#FUOOQO,59f,59fO!UQPO,59fO(uQPO'#FQO,eQPO,59cOOQO'#Dc'#DcOOQO'#Dd'#DdOOQO'#De'#DeO!vQQO'#DaOOQO'#FQ'#FQO,lQPO'#DfO,qQSO'#DjO,vQPO'#ExO-OQPO,5WQPO,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<WQPO,5;vO>]QPO1G/QOOQO1G.}1G.}OOQO'#DX'#DXOOQO'#DY'#DYOOQO'#DZ'#DZOOQO'#D['#D[OOQO'#D^'#D^OOQO'#D_'#D_OOQO'#D`'#D`O!vQQO,59oO!vQQO,59oO!vQQO,59oO!vQQO,59oO!vQQO,59oO!vQQO,59oO!vQQO,59oO!vQQO,59oO!vQQO,59oO>hQPO,5:nO?tQPO,59{OA^QQO'#DhOOQO,5:Q,5:QOBTQQO,5:UOB[QPO,5;fOOQO-E8x-E8xOOQO1G0o1G0oOOQO1G.m1G.mO&`QPO1G.pO!vQQO1G0[OBfQPO1G0_O!vQQO,5:zO!UQPO'#ExOBsQPO,5TQ`O'#FUO!AWQ`O,5:hO!*^QQO,5:jO!AwQSO,5:lOOQO<zAN>zOOQO<mOc$[OlTO#uPOm!]P~O!b$bO!d$cO!f$dO!o$eO!q$fO!s$gO!u$hO~O$b$iO~PAlOv#na#|#na~P#wO]RP#ORP#PRP~P]Ov#eO#V$ca~Og#iOo$tOl#Ya~Ov#oOS$eaU$eaW$eaY$ea[$ea_$eal$ea!Z$ea!y$ea!|$ea#R$ea#[$ea#d$ea#p$ea#u$ea#|$ea]$ea!z$ea#O$ea#P$ea~Ov#sOS$Pa#p$Pam$PaU$PaW$PaY$Pa[$Pa_$Pal$Pa!Z$Pa!y$Pa!|$Pa#R$Pa#[$Pa#d$Pa#u$Pa]$Pa!z$Pa#O$Pa#P$Pa~O`yOayObyOcyOdyOlTOqmO!U'PO!ZzO!_{O#uPO#wjO$SvO$WuO$^wO~Oj%OO~P+aOu%ZO~P'cOu%ZO#}%[O~O$]!|Ox!Tay!Taz!Ta!P!Ta$Q!Ta$R!Ta$S!Ta$T!Ta$U!Ta$V!Ta$W!Ta$X!Ta$Y!Ta$Z!Ta$[!Ta!b!Ta!d!Ta!f!Ta!o!Ta!q!Ta!s!Ta!u!Ta$b!Ta~Ou!Ta#}!Ta~PGgO!P#RO$R!wO$S!xO$T!yO$U!yO$V!zO$W!zO$X!{O$Y!{O$Z!{O$[!{O$]!|Omwixwiywizwi[wi!}wiSwivwi#pwijwiUwiWwiYwi_wilwi!Zwi!ywi!|wi#Rwi#[wi#dwi#uwi]wi!zwi#Owi#Pwi~O$Q!vO~PI_O$Qwi~PI_O!P#RO$T!yO$U!yO$V!zO$W!zO$X!{O$Y!{O$Z!{O$[!{O$]!|Omwixwiywizwi$Qwi$Swi[wi!}wiSwivwi#pwijwiUwiWwiYwi_wilwi!Zwi!ywi!|wi#Rwi#[wi#dwi#uwi]wi!zwi#Owi#Pwi~O$Rwi~PLVO$R!wO~PLVO$X!{O$Y!{O$Z!{O$[!{O$]!|Omwixwiywizwi$Qwi$Rwi$Swi$Twi$Uwi[wi!}wiSwivwi#pwijwiUwiWwiYwi_wilwi!Zwi!ywi!|wi#Rwi#[wi#dwi#uwi]wi!zwi#Owi#Pwi~O!P#RO$V!zO$W!zO~PN}O!Pwi$Vwi$Wwi~PN}O$]!|Omwixwiywi[wi!}wiSwivwi#pwijwiUwiWwiYwi_wilwi!Zwi!ywi!|wi#Rwi#[wi#dwi#uwi]wi!zwi#Owi#Pwi~Ozwi!Pwi$Qwi$Rwi$Swi$Twi$Uwi$Vwi$Wwi$Xwi$Ywi$Zwi$[wi~P!#{Oz!}O!P#RO$Q!vO$R!wO$S!xO$T!yO$U!yO$V!zO$W!zO$X!{O$Y!{O$Z!{O$[!{O$]!|Omwixwi[wi!}wiSwivwi#pwijwiUwiWwiYwi_wilwi!Zwi!ywi!|wi#Rwi#[wi#dwi#uwi]wi!zwi#Owi#Pwi~Oy#VO~P!&lOywi~P!&lO!d%^O~Ov%_Om$_X~P#wOv%_Om$_X~Om%aO~O$b%cO~PAlO!g%gO~O`yOayObyOcyOdyOlTOqmO!U'QO!ZzO!_{O#uPO#wjO$SvO$WuO$^wO~O!g%lO~O]%nO~OS!xqU!xqW!xqY!xq[!xq_!xql!xq!Z!xq!y!xq!|!xq#R!xq#[!xq#d!xq#p!xq#u!xq]!xq!z!xq#O!xq#P!xq~P+aO]%qO#O%pO#P%rO~Ov%sO~P+aO]%tO~O$g%vO~OS#gav#ga#p#gam#gaU#gaW#gaY#ga[#ga_#gal#ga!Z#ga!y#ga!|#ga#R#ga#[#ga#d#ga#u#ga]#ga!z#ga#O#ga#P#ga~P+aOusi#}si~P:wO#|%wO~O!P'ZO$R!wO$S!xO$T!yO$U!yO$V!zO$W!zO$X!{O$Y!{O$Z!{O$[!{O$]!|Ouwixwiywizwi#}wi!bwi!dwi!fwi!owi!qwi!swi!uwi$bwi~O$Q!vO~P!/[O$Qwi~P!/[O!P'ZO$T!yO$U!yO$V!zO$W!zO$X!{O$Y!{O$Z!{O$[!{O$]!|Ouwixwiywizwi#}wi$Qwi$Swi!bwi!dwi!fwi!owi!qwi!swi!uwi$bwi~O$Rwi~P!1ZO$R!wO~P!1ZO$X!{O$Y!{O$Z!{O$[!{O$]!|Ouwixwiywizwi#}wi$Qwi$Rwi$Swi$Twi$Uwi!bwi!dwi!fwi!owi!qwi!swi!uwi$bwi~O!P'ZO$V!zO$W!zO~P!3YO!Pwi$Vwi$Wwi~P!3YO$]!|Oxwiywizwi!Pwi$Qwi$Rwi$Swi$Twi$Uwi$Vwi$Wwi$Xwi$Ywi$Zwi$[wi!bwi!dwi!fwi!owi!qwi!swi!uwi$bwi~Ouwi#}wi~P!5_Oz'RO!P'ZO$Q!vO$R!wO$S!xO$T!yO$U!yO$V!zO$W!zO$X!{O$Y!{O$Z!{O$[!{O$]!|Ouwixwi#}wi!bwi!dwi!fwi!owi!qwi!swi!uwi$bwi~Oy'cO~P!7VOywi~P!7VOu%xO~P'cOc%|OlTO#uPO~Ov%_Om$_a~O#|&PO!b#yX!d#yX!f#yX!o#yX!q#yX!s#yX!u#yX$b#yX~P9SO!b!aa!d!aa!f!aa!o!aa!q!aa!s!aa!u!aa$b!aa~P:wO!b!ca!d!ca!f!ca!o!ca!q!ca!s!ca!u!ca$b!ca~P:wO!b!na!d!na!f!na!o!na!q!na!s!na!u!na$b!na~P:wOv#vXx#vXy#vXz#vX!P#vX!b#vX!d#vX!f#vX!o#vX!q#vX!s#vX!u#vX$Q#vX$R#vX$S#vX$T#vX$U#vX$V#vX$W#vX$X#vX$Y#vX$Z#vX$[#vX$]#vX$b#vX!i#vX!j#vX!k#vX~P!bO!w#WOv#xXx#xXy#xXz#xX!P#xX!b#xX!d#xX!f#xX!o#xX!q#xX!s#xX!u#xX$Q#xX$R#xX$S#xX$T#xX$U#xX$V#xX$W#xX$X#xX$Y#xX$Z#xX$[#xX$]#xX$b#xX!i#xX!j#xX!k#xX~P#wOx'bOy'dOz'SO!P'[O$Q!vO$R!wO$S!xO$T!yO$U!yO$V!zO$W!zO$X!{O$Y!{O$Z!{O$[!{O$]!|O~Ov&TO!b!pa!d!pa!f!pa!o!pa!q!pa!s!pa!u!pa$b!pa~P!@SO!b!ta!d!ta!f!ta!o!ta!q!ta!s!ta!u!ta$b!ta~P:wO]&YO#O%pO#P&ZO~Om&`O~P+aOm#hav#ha~P#wO]&aO~O!i&dO!j&dO!k&cOv!hX!b!hX!d!hX!f!hX!o!hX!q!hX!s!hX!u!hX$b!hX~P!@SOv&eO!b$aX!d$aX!f$aX!o$aX!q$aX!s$aX!u$aX$b$aX~Ov!Ta!i!Ta!j!Ta!k!Ta~PGgOv'eO!b$PX!d$PX!f$PX!o$PX!q$PX!s$PX!u$PX$b$PX~P!@SO!}&sO~P+aO]&tO~Ov&uO[#Sy~P+aOury#}ry~P:wO!b!aq!d!aq!f!aq!o!aq!q!aq!s!aq!u!aq$b!aq~P:wO!l&vO!m&vO~O!k&wOv!ha!b!ha!d!ha!f!ha!o!ha!q!ha!s!ha!u!ha$b!ha~Ov&eO!b$aa!d$aa!f$aa!o$aa!q$aa!s$aa!u$aa$b$aa~O!P'[O$R!wO$S!xO$T!yO$U!yO$V!zO$W!zO$X!{O$Y!{O$Z!{O$[!{O$]!|Ovwixwiywizwi!bwi!dwi!fwi!owi!qwi!swi!uwi$bwi!iwi!jwi!kwi~O$Q!vO~P!HgO$Qwi~P!HgO!P'[O$T!yO$U!yO$V!zO$W!zO$X!{O$Y!{O$Z!{O$[!{O$]!|Ovwixwiywizwi!bwi!dwi!fwi!owi!qwi!swi!uwi$Qwi$Swi$bwi!iwi!jwi!kwi~O$Rwi~P!JlO$R!wO~P!JlO$X!{O$Y!{O$Z!{O$[!{O$]!|Ovwixwiywizwi!bwi!dwi!fwi!owi!qwi!swi!uwi$Qwi$Rwi$Swi$Twi$Uwi$bwi!iwi!jwi!kwi~O!P'[O$V!zO$W!zO~P!LqO!Pwi$Vwi$Wwi~P!LqOvwi!iwi!jwi!kwi~P!5_Oz'SO!P'[O$Q!vO$R!wO$S!xO$T!yO$U!yO$V!zO$W!zO$X!{O$Y!{O$Z!{O$[!{O$]!|Ovwixwi!bwi!dwi!fwi!owi!qwi!swi!uwi$bwi!iwi!jwi!kwi~Oy'dO~P# ^Oywi~P# ^O!b!pq!d!pq!f!pq!o!pq!q!pq!s!pq!u!pq$b!pq~P:wOv'eO!b$Pa!d$Pa!f$Pa!o$Pa!q$Pa!s$Pa!u$Pa$b$Pa~O]&{O~O!l'OO!m'OO~Ov#ga!b#ga!d#ga!f#ga!o#ga!q#ga!s#ga!u#ga$b#ga~P!@SO[#S!Z~P+aOP$W~", + goto: "E]$fPPP$gP%VP%dP%VP%VPP%VPPPPPP(U)qP)qPP+ePP-XP(UP/T/T/TPP(]PPP/Z0U1V2TP3X4`5j(]P6z6z6z(]P8d8m(]P8p8tP8tP8tPP8xPPPPP8tP8tP8tP8tP(]P%VPP%VPPPP%VP9O9O9RP9U%V9b%VP%V9e%V9r9u9{:OP:_:n:t;O;V;];c;i;o;u;{PPPPR#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", + 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#s~XY#f[]#fpq#f#O#P#w~#zQYZ#f]^#f~$VP#r~]^$Y~$_O#r~~$dP#r~YZ$Ym$jP!_!`$mm$rOzmk$uXOY$rZ]$r^r$rrs%bs#O$r#O#P%g#P;'S$r;'S;=`'}<%lO$rk%gO#wkk%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$Z~~)oO$R~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$X~~.lO$V~o.sOvc#}[~.xP$W~}!O.{~/QTP~OY.{Z].{^;'S.{;'S;=`/a<%lO.{~/dP;=`<%l.{o/lPgi!O!P/oo/tP!Pm!O!P/wQ/|OcQ~0RQ$Y~!P!Q0X!_!`$m~0^O$[~~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$fQzm!^!_4`!_!`$mm4eO$Tm~4jP#|~!_!`$mo4tQ$gQzm!_!`$m!`!a4zm5PO$Um~5US#u~!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#wkO!_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#wk!_!`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#wk!_!`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#wkO!_>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$bn~DRO$]~~DWOq~~D]O$Q~~DbOu~~DgP$S~!_!`$m", tokenizers: [0, 1, 2, 3, 4], topRules: {"Chunk":[0,2]}, - dynamicPrecedences: {"136":1}, - specialized: [{term: 127, get: (value) => spec_identifier[value] || -1}], - tokenPrec: 4137 + dynamicPrecedences: {"138":1}, + specialized: [{term: 129, get: (value) => spec_identifier[value] || -1}], + tokenPrec: 4451 }) diff --git a/client/space_lua/parse-lua.terms.js b/client/space_lua/parse-lua.terms.js index df3ffa53..f4d2459f 100644 --- a/client/space_lua/parse-lua.terms.js +++ b/client/space_lua/parse-lua.terms.js @@ -24,15 +24,15 @@ export const 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 + IfStatement = 89, + ForStatement = 94, + ForNumeric = 96, + ForGeneric = 97, + NameList = 98, + ExpList = 100, + FuncName = 102, + VarList = 106, + AttNameList = 108, + AttName = 109, + Attrib = 110, + ReturnStatement = 111 diff --git a/client/space_lua/parse.ts b/client/space_lua/parse.ts index 415c4b5a..62b23998 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 asc nulls first last 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 filter": t.keyword, }); @@ -153,6 +153,11 @@ function expressionHasFunctionDef(e: LuaExpression): boolean { } } return false; + case "FilteredCall": + return ( + expressionHasFunctionDef(e.call) || + expressionHasFunctionDef(e.filter) + ); default: return false; } @@ -241,6 +246,11 @@ function exprReferencesNames(e: LuaExpression, names: Set): boolean { } } return false; + case "FilteredCall": + return ( + exprReferencesNames(e.call, names) || + exprReferencesNames(e.filter, names) + ); default: return false; } @@ -546,6 +556,11 @@ function exprCapturesNames(e: LuaExpression, names: Set): boolean { } } return false; + case "FilteredCall": + return ( + exprCapturesNames(e.call, names) || + exprCapturesNames(e.filter, names) + ); default: return false; } @@ -1208,6 +1223,16 @@ function parseExpression(t: ParseTree, ctx: ASTCtx): LuaExpression { clauses: t.children!.slice(2, -1).map((c) => parseQueryClause(c, ctx)), ctx: context(t, ctx), }; + case "FilteredCall": { + const call = parseFunctionCall(t.children![0], ctx); + const filterExpr = parseExpression(t.children![4], ctx); + return { + type: "FilteredCall", + call, + filter: filterExpr, + ctx: context(t, ctx), + }; + } default: console.error(t); throw new Error(`Unknown expression type: ${t.type}`); diff --git a/client/space_lua/query_collection.ts b/client/space_lua/query_collection.ts index 954c6d55..2765da9a 100644 --- a/client/space_lua/query_collection.ts +++ b/client/space_lua/query_collection.ts @@ -3,6 +3,7 @@ import type { LuaDynamicField, LuaExpression, LuaExpressionField, + LuaFilteredCallExpression, LuaFunctionCallExpression, LuaParenthesizedExpression, LuaPropField, @@ -197,6 +198,14 @@ export function toCollection(obj: any): LuaQueryCollection { function containsAggregate(expr: LuaExpression): boolean { switch (expr.type) { + case "FilteredCall": { + const fc = (expr as LuaFilteredCallExpression).call; + if (fc.prefix.type === "Variable" && getAggregateSpec(fc.prefix.name)) { + return true; + } + return containsAggregate(fc) || + containsAggregate((expr as LuaFilteredCallExpression).filter); + } case "FunctionCall": { const fc = expr as LuaFunctionCallExpression; if (fc.prefix.type === "Variable" && getAggregateSpec(fc.prefix.name)) { @@ -263,6 +272,31 @@ export async function evalExpressionWithAggregates( objectVariable, outerEnv, ); + + if (expr.type === "FilteredCall") { + const filtered = expr as LuaFilteredCallExpression; + const fc = filtered.call; + if (fc.prefix.type === "Variable") { + const name = fc.prefix.name; + const spec = getAggregateSpec(name); + if (spec) { + const valueExpr = fc.args.length > 0 ? fc.args[0] : null; + return executeAggregate( + spec, + groupItems, + valueExpr, + objectVariable, + outerEnv, + sf, + evalExpression, + filtered.filter, + ); + } + } + + return evalExpression(expr, env, sf); + } + if (expr.type === "FunctionCall") { const fc = expr as LuaFunctionCallExpression; if (fc.prefix.type === "Variable") { diff --git a/client/space_lua/query_test.lua b/client/space_lua/query_test.lua index 0c998389..9830103b 100644 --- a/client/space_lua/query_test.lua +++ b/client/space_lua/query_test.lua @@ -1722,3 +1722,224 @@ do assertEquals(r[1].name, "Fran") assertEquals(r[2].name, "Greg") end + +-- 45. Count with filter + +do + local r = query [[ + from + p = pages + where + p.tags[1] ~= nil + group by + p.tags[1] + select { + tag = key, + total = count(), + big = count(p.name) filter(where p.size > 10), + } + order by + tag + ]] + -- personal: Carol(5),Dave(15) -> big=1 (Dave) + -- random: Fran(1) -> big=0 + -- work: Alice(10),Bob(20),Greg(2) -> big=1 (Bob) + assertEquals(#r, 3) + for _, row in ipairs(r) do + if row.tag == "personal" then + assertEquals(row.big, 1) + assertEquals(row.total, 2) + elseif row.tag == "random" then + assertEquals(row.big, 0) + assertEquals(row.total, 1) + elseif row.tag == "work" then + assertEquals(row.big, 1) + assertEquals(row.total, 3) + end + end +end + +-- 46. Sum with filter + +do + local r = query [[ + from + p = pages + where + p.tags[1] ~= nil + group by + p.tags[1] + select { + tag = key, + total_size = sum(p.size), + big_size = sum(p.size) filter(where p.size > 5), + } + order by + tag + ]] + for _, row in ipairs(r) do + if row.tag == "work" then + -- Alice(10)+Bob(20)+Greg(2)=32 total, big: 10+20=30 + assertEquals(row.total_size, 32) + assertEquals(row.big_size, 30) + elseif row.tag == "personal" then + -- Carol(5)+Dave(15)=20 total, big: 15 + assertEquals(row.total_size, 20) + assertEquals(row.big_size, 15) + elseif row.tag == "random" then + -- Fran(1) total=1, big: 0 (none pass) + assertEquals(row.total_size, 1) + assertEquals(row.big_size, 0) + end + end +end + +-- 47. Min/max with filter + +do + local r = query [[ + from + p = pages + group by + "all" + select { + min_big = min(p.size) filter(where p.size > 5), + max_small = max(p.size) filter(where p.size <= 5), + } + ]] + assertEquals(#r, 1) + assertEquals(r[1].min_big, 10) -- smallest > 5: Alice(10) + assertEquals(r[1].max_small, 5) -- largest <= 5: Carol(5) +end + +-- 48. Avg with filter + +do + local r = query [[ + from + p = pages + group by + "all" + select { + avg_all = avg(p.size), + avg_big = avg(p.size) filter(where p.size >= 10), + } + ]] + assertEquals(#r, 1) + -- all: (10+20+5+15+3+1+2)/7 = 56/7 = 8 + assertEquals(r[1].avg_all, 8) + -- big (>=10): (10+20+15)/3 = 45/3 = 15 + assertEquals(r[1].avg_big, 15) +end + +-- 49. Array_agg with filter + +do + local r = query [[ + from + p = pages + group by + "all" + select { + all_names = array_agg(p.name), + big_names = array_agg(p.name) filter(where p.size > 10), + } + ]] + assertEquals(#r, 1) + assertEquals(#r[1].all_names, 7) + -- size > 10: Bob(20), Dave(15) + assertEquals(#r[1].big_names, 2) +end + +-- 50. Unbound access in filter + +do + local r = query [[ + from + pages + where + tags[1] ~= nil + group by + tags[1] + select { + tag = key, + big = count(name) filter(where size > 10), + } + order by + tag + ]] + for _, row in ipairs(r) do + if row.tag == "work" then + assertEquals(row.big, 1) -- Bob(20) + end + end +end + +-- 51. Count without argument with filter + +do + local r = query [[ + from + p = pages + group by + "all" + select { + total = count(), + big = count() filter(where p.size > 10), + } + ]] + assertEquals(r[1].total, 7) + assertEquals(r[1].big, 2) -- Bob(20), Dave(15) +end + +-- 52. Filter that matches nothing + +do + local r = query [[ + from + p = pages + group by + "all" + select { + n = count() filter(where p.size > 1000), + s = sum(p.size) filter(where p.size > 1000), + } + ]] + assertEquals(r[1].n, 0) + assertEquals(r[1].s, 0) +end + +-- 53. Multiple filters in one select + +do + local r = query [[ + from + p = pages + where + p.tags[1] ~= nil + group by + p.tags[1] + select { + tag = key, + young = count(p.name) filter(where p.age < 30), + old = count(p.name) filter(where p.age >= 50), + } + order by + tag + ]] + for _, row in ipairs(r) do + if row.tag == "work" then + -- young: Bob(25) -> 1; old: Greg(63) -> 1 + assertEquals(row.young, 1) + assertEquals(row.old, 1) + elseif row.tag == "personal" then + -- young: none; old: Dave(52) -> 1 + assertEquals(row.young, 0) + assertEquals(row.old, 1) + elseif row.tag == "random" then + -- young: none; old: Fran(55) -> 1 + assertEquals(row.young, 0) + assertEquals(row.old, 1) + end + end +end diff --git a/website/Space Lua/Lua Integrated Query/Aggregating.md b/website/Space Lua/Lua Integrated Query/Aggregating.md index aa74cca7..9a8c4f56 100644 --- a/website/Space Lua/Lua Integrated Query/Aggregating.md +++ b/website/Space Lua/Lua Integrated Query/Aggregating.md @@ -142,6 +142,45 @@ ${query [[ } ]]} +## Per-aggregate filtering with `filter(where ...)` + +Individual aggregate expressions can include a `filter(where )` clause to restrict which rows contribute to that specific aggregate. + +Unlike `where` (which filters rows before grouping) and `having` (which filters entire groups after aggregation), `filter(where ...)` applies per-aggregate, per-row within each group. Multiple aggregates in the same `select` can each have different filters. + +```lua +query[[ + from + p = index.tag 'page' + group by + p.tags[1] + select { + tag = key, + total = count(p.name), + big = count(p.name) filter(where p.size > 10), + big_sz = sum(p.size) filter(where p.size > 10) + } + order by + tag +]] +``` +${query [[ + from + p = index.tag 'page' + group by + p.tags[1] + select { + tag = key, + total = count(p.name), + big = count(p.name) filter(where p.size > 10), + big_sz = sum(p.size) filter(where p.size > 10) + } + order by + tag +]]} + +The filter clause works with all aggregate functions: `count`, `sum`, `min`, `max`, `avg`, `array_agg`, and custom aggregates. When no rows match the filter condition, aggregates return their identity value: `0` for `count` and `sum`, `nil` for `min`, `max`, and `avg`, and an empty table `{}` for `array_agg`. + ## Field access after grouping Non-aggregated field references, such as `name` in `select`, refer to the first item in the group, matching common SQL and MySQL semantics.