From cad71aa586e23eae2466f40600ff34fef43e7362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Jan=20Fialka?= Date: Fri, 27 Feb 2026 15:30:40 +0100 Subject: [PATCH] [LIQ] Fix: `group by` keys and collection handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix `group by` key building: populate array indices from `keyParts` instead of only from `keyRecord`, which was empty for some expressions (e.g. `tags[1]`). - Fix collection handling: treat record-like `LuaTables` (no array part) as items instead of extracting zero items. - Update test suite. Signed-off-by: Matouš Jan Fialka --- client/space_lua/eval.ts | 40 +- client/space_lua/query_collection.ts | 243 ++--- client/space_lua/query_test.lua | 1406 ++++++++++++++++++++++---- client/space_lua/runtime.ts | 8 +- 4 files changed, 1384 insertions(+), 313 deletions(-) diff --git a/client/space_lua/eval.ts b/client/space_lua/eval.ts index 6c8ab0d4..be86a8d2 100644 --- a/client/space_lua/eval.ts +++ b/client/space_lua/eval.ts @@ -38,10 +38,7 @@ import { luaValueToJS, singleResult, } from "./runtime.ts"; -import { - ArrayQueryCollection, - type LuaCollectionQuery, -} from "./query_collection.ts"; +import { type LuaCollectionQuery, toCollection } from "./query_collection.ts"; import { coerceNumericPair, coerceToNumber, @@ -187,9 +184,6 @@ type Queryable = { sf: LuaStackFrame, ) => Promise; }; -function isQueryable(x: unknown): x is Queryable { - return !!x && typeof (x as any).query === "function"; -} function arithVerbFromOperator(op: string): string | null { switch (op) { @@ -855,21 +849,24 @@ export function evalExpression( ); } if (collection instanceof LuaTable && collection.empty()) { - // Make sure we're converting an empty result to an array to "query" + // Empty table → empty array collection = []; - } else { - collection = luaValueToJS(collection, sf); - } - // Check if collection is a queryable collection - if (!isQueryable(collection)) { - if (!Array.isArray(collection)) { - throw new LuaRuntimeError( - "Collection does not support query", - sf.withCtx(q.ctx), - ); + } else if (collection instanceof LuaTable) { + if (collection.length > 0) { + // Array-like table: extract array items, keep as LuaTables + const arr: any[] = []; + for (let i = 1; i <= collection.length; i++) { + arr.push(collection.rawGet(i)); + } + collection = arr; + } else { + // Record-like table (no array part): treat as singleton + collection = [collection]; } - collection = new ArrayQueryCollection(collection); } + + collection = toCollection(collection); + // Build up query object const query: LuaCollectionQuery = { objectVariable, @@ -918,9 +915,8 @@ export function evalExpression( } } - return (collection as Queryable).query(query, env, sf).then( - jsToLuaValue, - ); + // Always use the possibly-wrapped collection + return collection.query(query, env, sf).then(jsToLuaValue); }, ); } diff --git a/client/space_lua/query_collection.ts b/client/space_lua/query_collection.ts index 5af8177e..53def980 100644 --- a/client/space_lua/query_collection.ts +++ b/client/space_lua/query_collection.ts @@ -8,6 +8,7 @@ import type { LuaPropField, LuaUnaryExpression, } from "./ast.ts"; + import { LuaEnv, luaGet, @@ -29,9 +30,6 @@ import type { KvKey } from "../../plug-api/types/datastore.ts"; import { executeAggregate, getAggregateSpec } from "./aggregates.ts"; -export { buildItemEnv } from "./query_env.ts"; -import { buildItemEnv } from "./query_env.ts"; - // Sentinel value representing SQL NULL in query results. export const LUA_SQL_NULL = Symbol.for("silverbullet.sqlNull"); @@ -39,51 +37,35 @@ export function isSqlNull(v: any): boolean { return v === LUA_SQL_NULL; } -// Build environment for post-`group by` clauses (`having`, `select`, -// `order by`). Injects `key` and `group` as top-level variables. Also -// unpacks the `group by` key fields as locals so that `group by name, -// tag` makes `name` and `tag` accessible. -// -// When an objectVariable is set (e.g. `from t = ...`), `t` is bound to -// the first group item so that `t.name` etc. keep working post-group, -// consistent with the non-binding path where bare field names come from -// the first item. +// Build environment for post-`group by` clauses. Injects `key` and `group` +// as top-level variables. Unpacks first group item fields and group-by key +// fields as locals so that bare field access works after grouping. function buildGroupItemEnv( objectVariable: string | undefined, groupByNames: string[] | undefined, item: any, - env: LuaEnv, + parentGlobals: LuaEnv, sf: LuaStackFrame, ): LuaEnv { - const itemEnv = new LuaEnv(env); - - // Always expose the raw group row as `_` + const itemEnv = new LuaEnv(parentGlobals); itemEnv.setLocal("_", item); - if (item instanceof LuaTable) { const keyVal = item.rawGet("key"); const groupVal = item.rawGet("group"); - - // Always unpack first group item's fields as locals, regardless of - // objectVariable, so bare field names (e.g. `name`) are accessible - // in having/select/order by after group by. const firstItem = (groupVal instanceof LuaTable) ? groupVal.rawGet(1) : undefined; if (firstItem) { for (const k of luaKeys(firstItem)) { - itemEnv.setLocal( - k, - luaGet(firstItem, k, sf.astCtx ?? null, sf), - ); + if (typeof k !== "string") continue; + itemEnv.setLocal(k, luaGet(firstItem, k, sf.astCtx ?? null, sf)); } } if (objectVariable) { itemEnv.setLocal(objectVariable, firstItem ?? item); } - if (keyVal !== undefined) { itemEnv.setLocal("key", keyVal); } @@ -93,14 +75,14 @@ function buildGroupItemEnv( if (keyVal instanceof LuaTable) { for (const k of luaKeys(keyVal)) { - itemEnv.setLocal( - k, - luaGet(keyVal, k, sf.astCtx ?? null, sf), - ); + if (typeof k !== "string") continue; + itemEnv.setLocal(k, luaGet(keyVal, k, sf.astCtx ?? null, sf)); } } + // For single-key group by, bind the key name as a local if ( - !(keyVal instanceof LuaTable) && groupByNames && + !(keyVal instanceof LuaTable) && + groupByNames && groupByNames.length === 1 ) { itemEnv.setLocal(groupByNames[0], keyVal); @@ -109,6 +91,43 @@ function buildGroupItemEnv( return itemEnv; } +/** + * Build an environment for evaluating per-item expressions in queries. + * + * When `objectVariable` is NOT set: item fields are unpacked as locals + * and shadow any globals. The item is also bound to `_`. + * + * When `objectVariable` IS set: only the object variable is bound. + * Item fields are NOT unpacked - the user opted into qualified access + * (e.g. `p.name`) and bare names must resolve from the parent env. + */ +function buildItemEnvLocal( + objectVariable: string | undefined, + item: any, + env: LuaEnv, + sf: LuaStackFrame, +): LuaEnv { + const itemEnv = new LuaEnv(env); + if (objectVariable) { + itemEnv.setLocal(objectVariable, item); + } else { + // Unpack item fields as locals so unqualified access works + itemEnv.setLocal("_", item); + if (item instanceof LuaTable) { + for (const key of luaKeys(item)) { + itemEnv.setLocal(key, luaGet(item, key, sf.astCtx ?? null, sf)); + } + } else if (typeof item === "object" && item !== null) { + for (const key of luaKeys(item)) { + itemEnv.setLocal(key, luaGet(item, key, sf.astCtx ?? null, sf)); + } + } + } + return itemEnv; +} + +export { buildItemEnvLocal as buildItemEnv }; + export type LuaOrderBy = { expr: LuaExpression; desc: boolean; @@ -151,7 +170,6 @@ export interface LuaQueryCollection { export class ArrayQueryCollection implements LuaQueryCollection { constructor(private readonly array: T[]) { } - query( query: LuaCollectionQuery, env: LuaEnv, @@ -162,7 +180,20 @@ export class ArrayQueryCollection implements LuaQueryCollection { } } -// Check whether an expression tree contains any aggregate function call. +// Wrap any object, array, or LuaQueryCollection as a queryable collection +export function toCollection(obj: any): LuaQueryCollection { + if ( + obj instanceof ArrayQueryCollection || + obj instanceof DataStoreQueryCollection + ) { + return obj; + } + if (Array.isArray(obj)) { + return new ArrayQueryCollection(obj); + } + return new ArrayQueryCollection([obj]); +} + function containsAggregate(expr: LuaExpression): boolean { switch (expr.type) { case "FunctionCall": { @@ -219,11 +250,9 @@ export async function evalExpressionWithAggregates( objectVariable: string | undefined, outerEnv: LuaEnv, ): Promise { - // Fast path: no aggregate calls in tree -> normal eval if (!containsAggregate(expr)) { return evalExpression(expr, env, sf); } - const recurse = (e: LuaExpression) => evalExpressionWithAggregates( e, @@ -233,8 +262,6 @@ export async function evalExpressionWithAggregates( objectVariable, outerEnv, ); - - // Direct aggregate call if (expr.type === "FunctionCall") { const fc = expr as LuaFunctionCallExpression; if (fc.prefix.type === "Variable") { @@ -254,8 +281,6 @@ export async function evalExpressionWithAggregates( } } } - - // Recurse into field values if (expr.type === "TableConstructor") { const table = new LuaTable(); let nextArrayIndex = 1; @@ -285,11 +310,8 @@ export async function evalExpressionWithAggregates( } return table; } - - // Recurse operands, apply via luaOp if (expr.type === "Binary") { const bin = expr as LuaBinaryExpression; - if (bin.operator === "and") { const left = singleResult(await recurse(bin.left)); if (!luaTruthy(left)) return left; @@ -300,7 +322,6 @@ export async function evalExpressionWithAggregates( if (luaTruthy(left)) return left; return singleResult(await recurse(bin.right)); } - const left = singleResult(await recurse(bin.left)); const right = singleResult(await recurse(bin.right)); return luaOp( @@ -313,8 +334,6 @@ export async function evalExpressionWithAggregates( sf, ); } - - // Recurse argument, apply operator if (expr.type === "Unary") { const un = expr as LuaUnaryExpression; const arg = singleResult(await recurse(un.argument)); @@ -340,13 +359,10 @@ export async function evalExpressionWithAggregates( return evalExpression(expr, env, sf); } } - - // Unwrap if (expr.type === "Parenthesized") { const paren = expr as LuaParenthesizedExpression; return singleResult(await recurse(paren.expression)); } - return evalExpression(expr, env, sf); } @@ -371,19 +387,13 @@ function collectCanonicalKeyOrder(results: any[]): string[] | null { return best; } -// After select, ensure every `LuaTable` result has the same string keys -// in the same insertion order. function normalizeSelectResults(results: any[]): any[] { if (results.length === 0) return results; - const canonicalKeys = collectCanonicalKeyOrder(results); if (!canonicalKeys || canonicalKeys.length === 0) return results; - for (let i = 0; i < results.length; i++) { const item = results[i]; if (!(item instanceof LuaTable)) continue; - - // Check if this table is missing any canonical keys let needsRebuild = false; for (const k of canonicalKeys) { const v = item.rawGet(k); @@ -393,7 +403,6 @@ function normalizeSelectResults(results: any[]): any[] { } } if (!needsRebuild) continue; - const rebuilt = new LuaTable(); for (const k of canonicalKeys) { const v = item.rawGet(k); @@ -412,8 +421,6 @@ function normalizeSelectResults(results: any[]): any[] { return results; } -// Handles both grouped (aggregate-aware) and non-grouped evaluation, -// optional select-alias injection, and collation async function orderByCompare( a: any, b: any, @@ -436,7 +443,6 @@ async function orderByCompare( for (const { expr, desc } of orderBy) { const aEnv = mkEnv(objectVariable, a, env, sf); const bEnv = mkEnv(objectVariable, b, env, sf); - if (aSelectRow) { for (const k of luaKeys(aSelectRow)) { const v = luaGet(aSelectRow, k, sf.astCtx ?? null, sf); @@ -449,7 +455,6 @@ async function orderByCompare( bEnv.setLocal(k, isSqlNull(v) ? null : v); } } - let aVal, bVal; if (grouped) { const aGroup = (a as LuaTable).rawGet("group"); @@ -474,13 +479,11 @@ async function orderByCompare( aVal = await evalExpression(expr, aEnv, sf); bVal = await evalExpression(expr, bEnv, sf); } - 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 ( collation?.enabled && typeof aVal === "string" && @@ -499,9 +502,6 @@ async function orderByCompare( return 0; } -/** - * Applies a given query (where, order by, limit etc.) to a set of results - */ export async function applyQuery( results: any[], query: LuaCollectionQuery, @@ -509,15 +509,16 @@ export async function applyQuery( sf: LuaStackFrame, collation?: QueryCollationConfig, ): Promise { - // Shallow copy to avoid updating underlying data structures results = results.slice(); - - // Filter results based on `where` clause first if (query.where) { const filteredResults = []; for (const value of results) { - // Enrich value - const itemEnv = buildItemEnv(query.objectVariable, value, env, sf); + const itemEnv = buildItemEnvLocal( + query.objectVariable, + value, + env, + sf, + ); if (await evalExpression(query.where, itemEnv, sf)) { filteredResults.push(value); } @@ -527,11 +528,11 @@ export async function applyQuery( const grouped = !!query.groupBy; - // Collect `group by` key names for unpacking into the environment. + // Collect group-by key names for unpacking into the post-group environment. let groupByNames: string[] | undefined; - // Apply `group by` if (query.groupBy) { + // Extract bare names from group-by expressions for local binding groupByNames = query.groupBy.map((expr) => { if (expr.type === "Variable") { return expr.name; @@ -542,39 +543,67 @@ export async function applyQuery( return undefined as unknown as string; }).filter(Boolean); - const groups = new Map(); + const groups = new Map< + string, + { key: any; items: any[] } + >(); + const groupByExprs = query.groupBy as LuaExpression[]; + for (const item of results) { - const itemEnv = buildItemEnv(query.objectVariable, item, env, sf); - // Evaluate all `group by` expressions to form a composite key + const itemEnv = buildItemEnvLocal( + query.objectVariable, + item, + env, + sf, + ); + const keyParts: any[] = []; - for (const expr of query.groupBy) { - keyParts.push(await evalExpression(expr, itemEnv, sf)); + const keyRecord: Record = {}; + + for (const expr of groupByExprs) { + if (expr.type === "Variable") { + const v = await evalExpression(expr, itemEnv, sf); + keyParts.push(v); + keyRecord[expr.name] = v; + } else if (expr.type === "PropertyAccess") { + const v = await evalExpression(expr, itemEnv, sf); + keyParts.push(v); + keyRecord[expr.property] = v; + } else { + const v = await evalExpression(expr, itemEnv, sf); + keyParts.push(v); + } } + const compositeKey = keyParts.length === 1 ? generateKey(keyParts[0]) : JSON.stringify(keyParts.map(generateKey)); let entry = groups.get(compositeKey); if (!entry) { - // Unwrap single key; multi-key to `LuaTable` with named fields let keyVal: any; if (keyParts.length === 1) { keyVal = keyParts[0]; } else { const kt = new LuaTable(); + // Always populate array indices from keyParts for (let i = 0; i < keyParts.length; i++) { kt.rawSetArrayIndex(i + 1, keyParts[i]); - if (groupByNames && groupByNames[i]) { - kt.rawSet(groupByNames[i], keyParts[i]); - } + } + // Additionally set named fields for Variable/PropertyAccess exprs + for (const name in keyRecord) { + kt.rawSet(name, keyRecord[name]); } keyVal = kt; } - entry = { key: keyVal, items: [] }; + entry = { + key: keyVal, + items: [], + }; groups.set(compositeKey, entry); } entry.items.push(item); } - // Convert groups to result rows with `key` and `group` + results = []; for (const { key, items } of groups.values()) { const groupTable = new LuaTable(); @@ -610,7 +639,12 @@ export async function applyQuery( env, ); } else { - const itemEnv = buildItemEnv(query.objectVariable, value, env, sf); + const itemEnv = buildItemEnvLocal( + query.objectVariable, + value, + env, + sf, + ); condResult = await evalExpression(query.having, itemEnv, sf); } if (condResult) { @@ -621,16 +655,16 @@ export async function applyQuery( } const mkEnv = grouped - ? (ov: string | undefined, item: any, e: LuaEnv, s: LuaStackFrame) => - buildGroupItemEnv(ov, groupByNames, item, e, s) - : buildItemEnv; + ? ( + ov: string | undefined, + item: any, + e: LuaEnv, + s: LuaStackFrame, + ) => buildGroupItemEnv(ov, groupByNames, item, e, s) + : buildItemEnvLocal; - // When grouped with both select and order by, pre-evaluate select for - // each group row so that order by can reference output aliases - // (e.g., `select { tot = count() } order by tot desc`). - // Store results in a parallel array to preserve insertion order of - // the select table's keys (avoids column reordering side effects). let selectResults: any[] | undefined; + if (grouped && query.select && query.orderBy) { selectResults = []; for (const item of results) { @@ -646,24 +680,18 @@ export async function applyQuery( ); selectResults.push(selected); } - // Normalize early so order by env injection also gets correct keys selectResults = normalizeSelectResults(selectResults); } - // Apply `order by` next if (query.orderBy) { - // Retrieve from config API if not passed if (collation === undefined) { // @ts-ignore: Hack to access client via the browser - const config = globalThis.client.config; // HACK: Shouldn't be using client here directly - + const config = globalThis.client.config; collation = config.get("queryCollation", {}); } - // Both arguments are optional, so passing undefined is fine const collator = Intl.Collator(collation?.locale, collation?.options); if (selectResults) { - // Sort via index array to keep results and selectResults in sync const indices = results.map((_, i) => i); await asyncQuickSort(indices, (ai, bi) => orderByCompare( @@ -681,7 +709,6 @@ export async function applyQuery( selectResults![bi], )); - // Reorder both arrays according to sorted indices results = indices.map((i) => results[i]); selectResults = indices.map((i) => selectResults![i]); } else { @@ -701,8 +728,6 @@ export async function applyQuery( } } - // Apply the select -- aggregate-aware when grouped - // When select was already pre-evaluated for order by, reuse those results if (query.select) { if (selectResults) { results = selectResults; @@ -728,31 +753,22 @@ export async function applyQuery( } results = newResult; } - - // Normalize: ensure all result tables have the same set of keys - // in the same insertion order, using LUA_SQL_NULL for nil gaps results = normalizeSelectResults(results); } - // Apply distinct filter (after select to filter on selected values) if (query.distinct) { const seen = new Set(); const distinctResult = []; - for (const item of results) { - // For non-primitive values, we use a JSON string as the key for comparison const key = generateKey(item); - if (!seen.has(key)) { seen.add(key); distinctResult.push(item); } } - results = distinctResult; } - // Apply the limit and offset if (query.limit !== undefined && query.offset !== undefined) { results = results.slice(query.offset, query.offset + query.limit); } else if (query.limit !== undefined) { @@ -773,7 +789,6 @@ export async function queryLua( enricher?: (key: KvKey, item: any) => any, ): Promise { const results: T[] = []; - // Accumulate all results into an array for await ( let { key, value } of kv.query({ prefix }) ) { @@ -782,11 +797,9 @@ export async function queryLua( } results.push(value); } - return applyQuery(results, query, env, sf); } -// Generate a stable string key for deduplication. function generateKey(value: any) { if (isSqlNull(value)) { return "__SQL_NULL__"; @@ -833,9 +846,7 @@ export class DataStoreQueryCollection implements LuaQueryCollection { constructor( private readonly dataStore: DataStore, readonly prefix: string[], - ) { - } - + ) {} query( query: LuaCollectionQuery, env: LuaEnv, diff --git a/client/space_lua/query_test.lua b/client/space_lua/query_test.lua index e8c3bf67..3266d43d 100644 --- a/client/space_lua/query_test.lua +++ b/client/space_lua/query_test.lua @@ -1,8 +1,17 @@ local function assertEquals(a, b) if a ~= b then - error("Assertion failed: " - .. tostring(a) .. " is not equal to " - .. tostring(b)) + error( + "Assertion failed: " + .. tostring(a) .. " (" .. type(a) .. ")" + .. " is not equal to " + .. tostring(b) .. " (" .. type(b) .. ")" + ) + end +end + +local function assertTrue(v, msg) + if not v then + error("Assertion failed: " .. (msg or "expected truthy value")) end end @@ -17,13 +26,13 @@ local pages = { { name = "Greg", tags = {"work", "fun"}, size = 2, age = 63 }, } --- 1. Basic `from` +-- 1. Basic `from` — all rows returned + do local r = query [[ from pages ]] - assertEquals(#r, #pages) end @@ -32,37 +41,38 @@ do from p = pages ]] - + assertEquals(#r, #pages) assertEquals(r[1].name, "Alice") end --- 2. Select/projection: direct, binding, mixed +-- 2. Select / projection + +-- 2a. Unbound: bare field names do local r = query [[ from pages select { n = name, - t = tags[1], } ]] - assertEquals(r[1].n, "Alice") end +-- 2b. Bound: qualified access do local r = query [[ from p = pages select { n = p.name, - t = p.tags[1], } ]] - + assertEquals(r[1].n, "Alice") assertEquals(r[2].n, "Bob") end +-- 2c. Unbound: mixed with nil-guard for undefined binding do local r = query [[ from @@ -72,85 +82,196 @@ do b = p and p.tags[1], } ]] - assertEquals(r[1].a, "Alice") end --- 3. Select field, select as array, order/limit/offset, where +-- 2d. Select single field (not table constructor) do - local r1 = query [[ + local r = query [[ from pages - select { - value = size, - } + select + name ]] - - assertEquals(r1[1].value, 10) - - local r2 = query [[ - from - p = pages - select { - value = p.size, - } - ]] - - assertEquals(r2[1].value, 10) + assertEquals(r[1], "Alice") + assertEquals(r[2], "Bob") end +-- 2e. Select single field, bound do - local r1 = query [[ + local r = query [[ from + p = pages + select + p.name + ]] + assertEquals(r[1], "Alice") + assertEquals(r[2], "Bob") +end + +-- 2f. Select with expression +do + local r = query [[ + from pages - limit 2 + select + name .. " (" .. size .. ")" + ]] + assertEquals(r[1], "Alice (10)") +end + +-- 2g. Select with expression, bound +do + local r = query [[ + from + p = pages + select + p.name .. " (" .. p.size .. ")" + ]] + assertEquals(r[1], "Alice (10)") +end + +-- 2h. Select whole object via object variable +do + local r = query [[ + from + p = pages + select + p + ]] + assertEquals(r[1].name, "Alice") + assertEquals(r[1].size, 10) +end + +-- 2i. Select table with aliased fields (key ~= value name) +do + local r = query [[ + from + pages select { - name = name, + pageName = name, + sz = size, } ]] + assertEquals(r[1].pageName, "Alice") + assertEquals(r[1].sz, 10) +end - assertEquals(#r1, 2) +-- 3. Limit and offset + +-- 3a. Limit only +do + local r = query [[ + from + pages + limit + 2 + ]] + assertEquals(#r, 2) +end - local r2 = query [[ +-- 3b. Limit with offset +do + local r = query [[ from p = pages - limit 3, 2 select { name = p.name, } + limit + 3, 2 ]] - - assertEquals(r2[1].name, "Carol") + assertEquals(#r, 3) + assertEquals(r[1].name, "Carol") end +-- 3c. Large offset reduces result count do - local r1 = query [[ + local r = query [[ + from + pages + limit + 100, 5 + ]] + assertEquals(#r, 2) +end + +-- 3d. Limit 0 returns empty +do + local r = query [[ + from + pages + limit + 0 + ]] + assertEquals(#r, 0) +end + +-- 4. Order by + +-- 4a. Order by field, unbound +do + local r = query [[ from pages - order by - size desc select { name = name, } + order by + size desc ]] + assertEquals(r[1].name, "Bob") +end - assert(r1[1].name == "Bob") - - local r2 = query [[ +-- 4b. Order by field, bound +do + local r = query [[ from p = pages - order by - p.age select { age = p.age, } + order by + p.age ]] - - assert(r2[1].age == 19) + assertEquals(r[1].age, 19) end +-- 4c. Order by multiple fields do - local r1 = query [[ + local r = query [[ + from + p = pages + select { + name = p.name, + size = p.size, + } + order by + p.size, p.name + ]] + -- size=1 Fran, size=2 Greg, size=3 Ed, size=5 Carol, ... + assertEquals(r[1].name, "Fran") + assertEquals(r[2].name, "Greg") +end + +-- 4d. Order by desc + limit +do + local r = query [[ + from + p = pages + order by + p.age desc + limit + 1 + ]] + assertEquals(r[1].name, "Greg") -- age 63 +end + +-- 5. Where + +-- 5a. Where, unbound +do + local r = query [[ from pages where @@ -159,10 +280,13 @@ do name = name, } ]] + assertEquals(#r, 2) -- Bob (20), Dave (15) + assertEquals(r[1].name, "Bob") +end - assert(r1[1].name == "Bob") - - local r2 = query [[ +-- 5b. Where, bound +do + local r = query [[ from p = pages where @@ -171,18 +295,91 @@ do name = p.name, } ]] - - assert(r2[1].name == "Bob", - "Expected Bob in r2[1], got " .. tostring(r2[1] and r2[1].name)) - - assert(r2[2].name == "Ed", - "Expected Ed in r2[2], got " .. tostring(r2[2] and r2[2].name)) + assertEquals(r[1].name, "Bob") + assertEquals(r[2].name, "Ed") end --- 4. Grouping/group select - +-- 5c. Where with truthy check, unbound do - local r1 = query [[ + local r = query [[ + from + pages + where + name + ]] + assertEquals(#r, #pages) +end + +-- 5d. Where with nil check on nested field, unbound +do + local r = query [[ + from + pages + where + tags[1] ~= nil + ]] + -- Ed has empty tags -> tags[1] is nil -> excluded + assertEquals(#r, 6) +end + +-- 5e. Where with nil check, bound +do + local r = query [[ + from + p = pages + where + p.tags[1] ~= nil + ]] + assertEquals(#r, 6) +end + +-- 5f. Where + order by + limit + select (full pipeline without grouping) +do + local r = query [[ + from + p = pages + where + p.size > 2 + select { + name = p.name, + size = p.size, + } + order by + p.name + limit + 3 + ]] + assertEquals(#r, 3) + assertEquals(r[1].name, "Alice") + assertEquals(r[2].name, "Bob") + assertEquals(r[3].name, "Carol") +end + +-- 5g. Where + order by + limit, unbound +do + local r = query [[ + from + pages + where + size > 2 + select { + name = name, + size = size, + } + order by + name + limit + 3 + ]] + assertEquals(#r, 3) + assertEquals(r[1].name, "Alice") +end + +-- 6. Group by — single key + +-- 6a. Group by with where filter, unbound +do + local r = query [[ from pages where @@ -193,7 +390,14 @@ do key = key, } ]] - local r2 = query [[ + assertTrue(type(r[1].key) == "string" or r[1].key == nil) + -- work, personal, random -> 3 groups + assertEquals(#r, 3) +end + +-- 6b. Group by with where filter, bound +do + local r = query [[ from p = pages where @@ -204,13 +408,66 @@ do key = key, } ]] - - assert(type(r1[1].key) == "string" or r1[1].key == nil) - assert(#r1 == #r2) + assertEquals(#r, 3) end +-- 6c. Group by with group access do - local r1 = query [[ + local r = query [[ + from + pages + where + tags[1] ~= nil + group by + tags[1] + select { + k = key, + gc = #group, + first = group[1].name, + } + ]] + assertTrue(type(r[1].gc) == "number") +end + +-- 6d. Group by bound with group access +do + local r = query [[ + from + p = pages + where + p.tags[1] ~= nil + group by + p.tags[1] + select { + k = key, + gc = #group, + n = group[1].name, + } + ]] + assertTrue(type(r[1].gc) == "number") +end + +-- 6e. Group by nil key (Ed has empty tags, tags[1] is nil) +do + local r = query [[ + from + pages + group by + tags[1] + select { + k = key, + gc = #group, + } + ]] + -- 4 groups: work, personal, random, nil (Ed) + assertEquals(#r, 4) +end + +-- 7. Group by — composite key + +-- 7a. Multi-key group by, unbound +do + local r = query [[ from pages where @@ -222,7 +479,13 @@ do k2 = key[2], } ]] - local r2 = query [[ + -- Alice: work,urgent; Carol: personal,urgent; Greg: work,fun -> 3 combos + assertEquals(#r, 3) +end + +-- 7b. Multi-key group by, bound +do + local r = query [[ from p = pages where @@ -234,66 +497,18 @@ do k2 = key[2], } ]] - - assert(#r1 == #r2) + assertEquals(#r, 3) end -do - local r = query [[ - from - pages - group by - tags[1] - select { - k = key, - gc = #group, - first = group[1].name, - f2 = group[1].tags[2], - } - ]] - - assert(type(r[1].k) == "string" or r[1].k == nil) - assert(type(r[1].gc) == "number") -end - -do - local r = query [[ - from - p = pages - group by - p.tags[1] - select { - k = key, - gc = #group, - n = group[1].name, - } - ]] - - assert(type(r[1].gc) == "number") -end - -do - local r = query [[ - from - pages - group by - tags[1] - select { - k = key, - n = group[1].name, - t = group[1].tags[1], - } - ]] - - assert(type(r[1].k) == "string" or r[1].k == nil) -end - --- 5. Aggregation/builtins +-- 8. Aggregation builtins +-- 8a. count do local r1 = query [[ from pages + where + tags[1] ~= nil group by tags[1] select { @@ -303,53 +518,47 @@ do local r2 = query [[ from p = pages + where + p.tags[1] ~= nil group by p.tags[1] select { count = count(p.name), } ]] - - assert(type(r1[1].count) == "number") - assert(#r1 == #r2) + assertTrue(type(r1[1].count) == "number") + assertEquals(#r1, #r2) end +-- 8b. min, max, avg, sum do - local r1 = query [[ - from - pages - group by - tags[1] - select { - min = min(age), - max = max(age), - avg = avg(age), - sum = sum(age), - } - ]] - local r2 = query [[ + local r = query [[ from p = pages + where + p.tags[1] ~= nil group by p.tags[1] select { - min = min(p.size), - max = max(p.size), - avg = avg(p.size), - sum = sum(p.size), + mn = min(p.size), + mx = max(p.size), + av = avg(p.size), + sm = sum(p.size), } ]] - - for _, row in ipairs(r2) do - assert(type(row.min) == "number" or row.min == nil) - assert(type(row.avg) == "number" or row.avg == nil) + for _, row in ipairs(r) do + assertTrue(type(row.mn) == "number" or row.mn == nil) + assertTrue(type(row.av) == "number" or row.av == nil) end end +-- 8c. array_agg do local r1 = query [[ from pages + where + tags[1] ~= nil group by tags[1] select { @@ -359,39 +568,67 @@ do local r2 = query [[ from p = pages + where + p.tags[1] ~= nil group by p.tags[1] select { arr = array_agg(p.name), } ]] - - assert(type(r1[1].arr) == "table" or r1[1].arr == nil) - assert(#r1 == #r2) + assertTrue(type(r1[1].arr) == "table" or r1[1].arr == nil) + assertEquals(#r1, #r2) end +-- 8d. count() with no argument (counts all rows) +do + local r = query [[ + from + p = pages + where + p.tags[1] ~= nil + group by + p.tags[1] + select { + k = key, + n = count(), + } + ]] + assertTrue(type(r[1].n) == "number") +end + +-- 8e. Multiple aggregates in one select do local r = query [[ from pages + where + tags[1] ~= nil group by tags[1] select { c = count(name), - v = min(size), - x = p and count(p.name), + lo = min(size), + hi = max(size), + av = avg(size), + sm = sum(size), + arr = array_agg(name), } ]] - - assert(type(r[1].c) == "number") + assertTrue(type(r[1].c) == "number") + assertTrue(type(r[1].lo) == "number" or r[1].lo == nil) + assertTrue(type(r[1].arr) == "table" or r[1].arr == nil) end --- 6. `having` +-- 9. Having +-- 9a. Having with aggregate, unbound do - local r1 = query [[ + local r = query [[ from pages + where + tags[1] ~= nil group by tags[1] having @@ -400,9 +637,18 @@ do key = key, } ]] - local r2 = query [[ + -- work: Alice,Bob,Greg (3); personal: Carol,Dave (2); random: Fran (1) + -- Only work and personal pass + assertEquals(#r, 2) +end + +-- 9b. Having with aggregate, bound +do + local r = query [[ from p = pages + where + p.tags[1] ~= nil group by p.tags[1] having @@ -411,14 +657,16 @@ do key = key, } ]] - - assertEquals(#r1, #r2) + assertEquals(#r, 2) end +-- 9c. Having with sum do local r = query [[ from p = pages + where + p.tags[1] ~= nil group by p.tags[1] having @@ -427,12 +675,62 @@ do key = key, } ]] - - assert((#r > 0), "expected some groups") + -- work: 10+20+2=32; personal: 5+15=20; random: 1 + -- work and personal pass + assertEquals(#r, 2) end --- 7. Full pipeline +-- 9d. Having without group by (acts as secondary filter), unbound +do + local r = query [[ + from + pages + having + size > 10 + select { + name = name, + } + ]] + assertEquals(#r, 2) -- Bob (20), Dave (15) +end +-- 9e. Having without group by, bound +do + local r = query [[ + from + p = pages + having + p.size > 10 + select { + name = p.name, + } + ]] + assertEquals(#r, 2) +end + +-- 9f. Where + having without group by (both filter) +do + local r = query [[ + from + pages + where + age > 20 + having + size > 10 + select { + name = name, + } + ]] + -- age>20: Alice(31),Bob(25),Carol(41),Dave(52),Fran(55),Greg(63) + -- size>10: Bob(20),Dave(15) + assertEquals(#r, 2) + assertEquals(r[1].name, "Bob") + assertEquals(r[2].name, "Dave") +end + +-- 10. Group by + having + order by + select + limit (full pipeline) + +-- 10a. Bound do local r = query [[ from @@ -448,17 +746,18 @@ do top = max(p.name), total = count(p.name), sum_size = sum(p.size), - min_age = min(age), avg_size = avg(p.size), } order by avg_size desc - limit 2 + limit + 2 ]] - - assert(#r <= 2) + assertTrue(#r <= 2) + assertTrue(type(r[1].avg_size) == "number" or r[1].avg_size == nil) end +-- 10b. Unbound do local r = query [[ from @@ -474,17 +773,301 @@ do top = max(name), total = count(name), sum_size = sum(size), - min_age = min(age), avg_size = avg(size), } order by avg_size desc - limit 2 + limit + 2 ]] - - assert(#r <= 2) + assertTrue(#r <= 2) end +-- 11. Distinct + +-- 11a. Distinct with select, unbound +do + local r = query [[ + from + pages + where + tags[1] ~= nil + select { + tag = tags[1], + } + ]] + -- default distinct=true for queries, so should deduplicate + -- work appears 3x, personal 2x, random 1x -> 3 distinct + assertEquals(#r, 3) +end + +-- 11b. Distinct with select, bound +do + local r = query [[ + from + p = pages + where + p.tags[1] ~= nil + select { + tag = p.tags[1], + } + ]] + assertEquals(#r, 3) +end + +-- 12. Edge cases: empty results + +-- 12a. Where that matches nothing +do + local r = query [[ + from + pages + where + size > 1000 + ]] + assertEquals(#r, 0) +end + +-- 12b. Having that matches nothing +do + local r = query [[ + from + p = pages + where + p.tags[1] ~= nil + group by + p.tags[1] + having + count(p.name) > 100 + select { + key = key, + } + ]] + assertEquals(#r, 0) +end + +-- 12c. Empty source +do + local empty = {} + local r = query [[ + from + empty + ]] + assertEquals(#r, 0) +end + +-- 13. Group by + order by on aggregate result + +-- 13a. Order by count desc +do + local r = query [[ + from + pages + where + tags[1] ~= nil + group by + tags[1] + select { + tag = key, + n = count(), + } + order by + n desc + ]] + -- work=3, personal=2, random=1 + assertTrue(r[1].n >= r[2].n) +end + +-- 13b. Order by key +do + local r = query [[ + from + p = pages + where + p.tags[1] ~= nil + group by + p.tags[1] + select { + tag = key, + n = count(), + } + order by + tag + ]] + -- alphabetical: personal, random, work + assertEquals(r[1].tag, "personal") + assertEquals(r[2].tag, "random") + assertEquals(r[3].tag, "work") +end + +-- 14. Group by key name binding + +-- 14a. Single key name available via key variable in select +do + local found_tag = false + local r = query [[ + from + pages + where + tags[1] ~= nil + group by + tags[1] + select { + k = key, + } + ]] + for _, row in ipairs(r) do + if row.k == "work" then + found_tag = true + end + end + assertTrue(found_tag, "expected to find 'work' group key") +end + +-- 15. Caller-injected env variable must not be shadowed by item fields +-- (the requeueTimeouts / mq pattern) + +do + local threshold = 100 + local items = { + { id = "a", ts = 50 }, + { id = "b", ts = 200 }, + } + -- m.ts < ts where ts=threshold from parent env + -- m.ts=50 < 100 -> true; m.ts=200 < 100 -> false + local ts = threshold + local r = query [[ + from + m = items + where + m.ts < ts + ]] + assertEquals(#r, 1) + assertEquals(r[1].id, "a") +end + +-- 16. Unbound: item fields shadow outer locals + +do + local size = 999 -- this should be shadowed by item.size + local r = query [[ + from + pages + where + size < 10 + select { + name = name, + sz = size, + } + ]] + -- size < 10: Carol(5), Ed(3), Fran(1), Greg(2) -> 4 items + assertEquals(#r, 4) + assertTrue(r[1].sz < 10) +end + +-- 17. Bound: outer locals are accessible + +do + local threshold = 10 + local r = query [[ + from + p = pages + where + p.size > threshold + select { + name = p.name, + } + ]] + -- size > 10: Bob(20), Dave(15) -> 2 + assertEquals(#r, 2) +end + +-- 18. Select with table constructor + non-Variable expressions + +-- 18a. Expression value in PropField +do + local r = query [[ + from + p = pages + select { + label = p.name .. "!", + double = p.size * 2, + } + limit + 2 + ]] + assertEquals(r[1].label, "Alice!") + assertEquals(r[1].double, 20) + assertEquals(r[2].label, "Bob!") + assertEquals(r[2].double, 40) +end + +-- 18b. Unbound expression +do + local r = query [[ + from + pages + select { + label = name .. "!", + double = size * 2, + } + limit + 2 + ]] + assertEquals(r[1].label, "Alice!") + assertEquals(r[1].double, 20) +end + +-- 19. Order by + group by + having + limit combined + +do + local r = query [[ + from + p = pages + where + p.tags[1] ~= nil + group by + p.tags[1] + having + count() > 0 + select { + tag = key, + total = sum(p.size), + } + order by + total desc + limit + 2 + ]] + assertEquals(#r, 2) + assertTrue(r[1].total >= r[2].total) +end + +-- 20. Offset with group by + +do + local r = query [[ + from + p = pages + where + p.tags[1] ~= nil + group by + p.tags[1] + select { + tag = key, + n = count(), + } + order by + tag + limit + 2, 1 + ]] + -- 3 groups sorted by tag, skip 1, take 2 + assertEquals(#r, 2) +end + +-- 21. Where + having + group by combined + do local r = query [[ from @@ -494,17 +1077,494 @@ do group by tags[1] having - min(age) > 25 + #group > 1 + select { + tag = key, + gc = #group, + } + ]] + -- After where (age>20): Alice(31),Bob(25),Carol(41),Dave(52),Fran(55),Greg(63) + -- Groups by tags[1]: work=[Alice,Bob,Greg](3), personal=[Carol,Dave](2), random=[Fran](1) + -- Having #group>1: work(3), personal(2) + assertEquals(#r, 2) +end + +-- Same, bound +do + local r = query [[ + from + p = pages + where + p.age > 20 + group by + p.tags[1] + having + #group > 1 + select { + tag = key, + gc = #group, + } + ]] + assertEquals(#r, 2) +end + +-- 22. Select _ (the whole item, unbound) + +do + local r = query [[ + from + pages + select + _ + limit + 1 + ]] + assertEquals(r[1].name, "Alice") + assertEquals(r[1].size, 10) +end + +-- 23. Group by with order by on key + +do + local r = query [[ + from + p = pages + where + p.tags[1] ~= nil + group by + p.tags[1] select { tag = key, - p = p and p.name, - total = count(name), - avg_size = avg(size), } order by - avg_size desc - limit 2 + key ]] + assertEquals(r[1].tag, "personal") + assertEquals(r[2].tag, "random") + assertEquals(r[3].tag, "work") +end - assert(#r <= 2) +-- 24. Aggregate on entire dataset (single group) + +do + local r = query [[ + from + p = pages + group by + "all" + select { + total = count(), + min_size = min(p.size), + max_size = max(p.size), + sum_size = sum(p.size), + } + ]] + assertEquals(#r, 1) + assertEquals(r[1].total, 7) + assertEquals(r[1].min_size, 1) + assertEquals(r[1].max_size, 20) + assertEquals(r[1].sum_size, 56) -- 10+20+5+15+3+1+2 +end + +-- 25. Nested field access in group by + +do + local r = query [[ + from + pages + where + tags[1] ~= nil + group by + tags[1] + select { + k = key, + first_name = group[1].name, + first_tag2 = group[1].tags[2], + } + ]] + assertTrue(type(r[1].k) == "string" or r[1].k == nil) +end + +-- 26. Mixed aggregate and non-aggregate in select + +do + local r = query [[ + from + pages + where + tags[1] ~= nil + group by + tags[1] + select { + c = count(name), + v = min(size), + x = p and count(p.name), + } + ]] + assertTrue(type(r[1].c) == "number") +end + +-- 27. Having with #group + +do + local r = query [[ + from + p = pages + where + p.tags[1] ~= nil + group by + p.tags[1] + having + #group >= 2 + select { + tag = key, + gc = #group, + } + ]] + for _, row in ipairs(r) do + assertTrue(row.gc >= 2, "expected group count >= 2, got " .. tostring(row.gc)) + end +end + +-- 28. Order by desc on aggregate + +do + local r = query [[ + from + p = pages + where + p.tags[1] ~= nil + group by + p.tags[1] + select { + tag = key, + sm = sum(p.size), + } + order by + sm desc + ]] + for i = 1, #r - 1 do + assertTrue( + (r[i].sm or 0) >= (r[i + 1].sm or 0), + "expected descending sum order" + ) + end +end + +-- 29. Singleton collection (single object, not array) + +do + local item = { name = "solo", size = 42 } + local r = query [[ + from + item + select { + name = name, + size = size, + } + ]] + assertEquals(#r, 1) + assertEquals(r[1].name, "solo") + assertEquals(r[1].size, 42) +end + +do + local item = { name = "solo", size = 42 } + local r = query [[ + from + p = item + select { + name = p.name, + size = p.size, + } + ]] + assertEquals(#r, 1) + assertEquals(r[1].name, "solo") +end + +-- 30. Where on singleton + +do + local item = { name = "solo", size = 42 } + local r = query [[ + from + item + where + size > 100 + ]] + assertEquals(#r, 0) +end + +do + local item = { name = "solo", size = 42 } + local r = query [[ + from + item + where + size > 10 + select { + name = name, + } + ]] + assertEquals(#r, 1) + assertEquals(r[1].name, "solo") +end + +-- 31. Where with comparison operators + +do + local r = query [[ + from + pages + where + size == 10 + ]] + assertEquals(#r, 1) + assertEquals(r[1].name, "Alice") +end + +do + local r = query [[ + from + pages + where + size >= 15 + ]] + assertEquals(#r, 2) -- Bob(20), Dave(15) +end + +do + local r = query [[ + from + pages + where + size <= 3 + ]] + assertEquals(#r, 3) -- Ed(3), Fran(1), Greg(2) +end + +-- 32. Where with logical operators + +do + local r = query [[ + from + pages + where + size > 10 and age < 40 + ]] + -- Bob: size=20, age=25 -> true + assertEquals(#r, 1) + assertEquals(r[1].name, "Bob") +end + +do + local r = query [[ + from + pages + where + size > 15 or age > 60 + ]] + -- Bob: size=20 -> true; Greg: age=63 -> true + assertEquals(#r, 2) +end + +do + local r = query [[ + from + p = pages + where + p.size > 10 and p.age < 40 + ]] + assertEquals(#r, 1) + assertEquals(r[1].name, "Bob") +end + +-- 33. Where with not + +do + local r = query [[ + from + pages + where + not (size > 10) + ]] + -- size <= 10: Alice(10), Carol(5), Ed(3), Fran(1), Greg(2) -> 5 + assertEquals(#r, 5) +end + +-- 34. Order by with nulls + +do + local data = { + { name = "a", val = 3 }, + { name = "b" }, + { name = "c", val = 1 }, + } + local r = query [[ + from + p = data + select { + name = p.name, + } + order by + p.val + ]] + -- nulls sort last in ascending + assertEquals(r[1].name, "c") + assertEquals(r[2].name, "a") + assertEquals(r[3].name, "b") +end + +-- 35. Select preserves order after where + order by + +do + local r = query [[ + from + p = pages + where + p.size >= 5 + select { + name = p.name, + size = p.size, + } + order by + p.size + ]] + -- size >= 5: Carol(5), Alice(10), Dave(15), Bob(20) in order + assertEquals(r[1].name, "Carol") + assertEquals(r[2].name, "Alice") + assertEquals(r[3].name, "Dave") + assertEquals(r[4].name, "Bob") +end + +-- 36. Multiple group by keys produce composite key table + +do + local data = { + { a = "x", b = 1, v = 10 }, + { a = "x", b = 1, v = 20 }, + { a = "x", b = 2, v = 30 }, + { a = "y", b = 1, v = 40 }, + } + local r = query [[ + from + p = data + group by + p.a, p.b + select { + ka = key[1], + kb = key[2], + total = sum(p.v), + } + order by + ka, kb + ]] + assertEquals(#r, 3) -- (x,1), (x,2), (y,1) + assertEquals(r[1].ka, "x") + assertEquals(r[1].kb, 1) + assertEquals(r[1].total, 30) -- 10+20 + assertEquals(r[2].ka, "x") + assertEquals(r[2].kb, 2) + assertEquals(r[2].total, 30) + assertEquals(r[3].ka, "y") + assertEquals(r[3].total, 40) +end + +-- 37. Having on composite group by + +do + local data = { + { a = "x", b = 1, v = 10 }, + { a = "x", b = 1, v = 20 }, + { a = "x", b = 2, v = 30 }, + { a = "y", b = 1, v = 40 }, + } + local r = query [[ + from + p = data + group by + p.a, p.b + having + count() > 1 + select { + ka = key[1], + kb = key[2], + n = count(), + } + ]] + -- Only (x,1) has 2 items + assertEquals(#r, 1) + assertEquals(r[1].ka, "x") + assertEquals(r[1].kb, 1) + assertEquals(r[1].n, 2) +end + +-- 38. Bound: select p returns full item + +do + local r = query [[ + from + p = pages + select + p + limit + 1 + ]] + assertEquals(r[1].name, "Alice") + assertEquals(r[1].age, 31) + assertEquals(r[1].size, 10) +end + +-- 39. Numeric aggregates with bound and unbound + +do + local nums = { + { val = 10 }, + { val = 20 }, + { val = 30 }, + } + local r = query [[ + from + nums + group by + "all" + select { + mn = min(val), + mx = max(val), + av = avg(val), + sm = sum(val), + ct = count(), + } + ]] + assertEquals(r[1].mn, 10) + assertEquals(r[1].mx, 30) + assertEquals(r[1].av, 20) + assertEquals(r[1].sm, 60) + assertEquals(r[1].ct, 3) +end + +do + local nums = { + { val = 10 }, + { val = 20 }, + { val = 30 }, + } + local r = query [[ + from + n = nums + group by + "all" + select { + mn = min(n.val), + mx = max(n.val), + av = avg(n.val), + sm = sum(n.val), + ct = count(), + } + ]] + assertEquals(r[1].mn, 10) + assertEquals(r[1].mx, 30) + assertEquals(r[1].av, 20) + assertEquals(r[1].sm, 60) + assertEquals(r[1].ct, 3) end diff --git a/client/space_lua/runtime.ts b/client/space_lua/runtime.ts index 3410d446..c621860c 100644 --- a/client/space_lua/runtime.ts +++ b/client/space_lua/runtime.ts @@ -1266,11 +1266,15 @@ export function luaGet( if (obj instanceof LuaTable || obj instanceof LuaEnv) { return obj.get(key, sf); } + // Native JS array access: normalize undefined → null (Lua nil). + // Without this, accessing an out-of-bounds index on a JS array + // leaks JS undefined into the Lua runtime, breaking nil checks + // such as `tags[1] ~= nil` on an empty array. if (typeof key === "number") { - return (obj as any[])[key - 1]; + return (obj as any[])[key - 1] ?? null; } if (isTaggedFloat(key)) { - return (obj as any[])[key.value - 1]; + return (obj as any[])[key.value - 1] ?? null; } // Native JS object const k = toNumKey(key);