From 796cd15b7dd5aef2a1df168e43343529112c585a Mon Sep 17 00:00:00 2001 From: Ben Bernard Date: Tue, 24 Feb 2026 16:26:23 -0800 Subject: [PATCH] feat: replace regex-based {{}} expansion with lvalue/Proxy approach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the fragile 3-step regex pipeline in transformCode() with a single regex that produces language-native lvalue expressions. The language itself now handles reads, writes, compound assignments, and increment/decrement — no assignment-detection regex needed. - JS: {{x}} → __F["x"] via Proxy get/set traps - Python: {{x}} → __F["x"] via __getitem__/__setitem__ - Perl: {{x}} → _f("x") via lvalue sub returning hash element Fixes bugs where the old [^;,\n]+ RHS capture regex broke on commas in function calls, array literals, object literals, and ternaries. Co-Authored-By: Claude Opus 4.6 --- src/Executor.ts | 86 +++++----- src/snippets/PerlSnippetRunner.ts | 2 +- src/snippets/PythonSnippetRunner.ts | 2 +- src/snippets/perl/runner.pl | 44 +++-- src/snippets/python/runner.py | 33 +++- tests/Executor.test.ts | 199 ++++++++++++++++------- tests/snippets/TemplateExpansion.test.ts | 16 +- 7 files changed, 248 insertions(+), 134 deletions(-) diff --git a/src/Executor.ts b/src/Executor.ts index 5ed3384..984ba7b 100644 --- a/src/Executor.ts +++ b/src/Executor.ts @@ -89,44 +89,30 @@ interface CompiledSnippet { } /** - * Transform {{keyspec}} syntax into key lookups. + * Transform {{keyspec}} syntax into accessor expressions. * - * {{foo/bar}} becomes calls to the __get helper function. - * {{foo/bar}} = value becomes calls to the __set helper function. - * {{foo/bar}} += value becomes __set(R, "foo/bar", __get(R, "foo/bar") + value) + * Uses a language-native lvalue approach: the expansion produces an expression + * that can appear on both the left and right side of assignments. This means + * the language itself handles reads, writes, compound assignments (+=, *=, etc.), + * and even increment/decrement — no regex-based assignment detection needed. * - * The recordVar parameter controls the variable name used in generated code - * (e.g. "r" for JS/Python, "$r" for Perl). + * For JS/Python ("accessor" style): {{foo/bar}} becomes __F["foo/bar"] + * where __F is a Proxy (JS) or __getitem__/__setitem__ object (Python). + * + * For Perl ("lvalue" style): {{foo/bar}} becomes _f("foo/bar") + * where _f is a Perl lvalue sub that returns a modifiable hash element. */ -export function transformCode(code: string, recordVar = "r"): string { - // Step 1: Replace compound assignments like {{ks}} += val - // Operators listed longest-first for correct matching - let transformed = code.replace( - /\{\{(.*?)\}\}\s*(\*\*|>>>|>>|<<|\?\?|\/\/|\|\||&&|\+|-|\*|\/|%|&|\||\^)=\s*([^;,\n]+)/g, - (_match, keyspec: string, op: string, value: string) => { - const ks = JSON.stringify(keyspec); - return `__set(${recordVar}, ${ks}, __get(${recordVar}, ${ks}) ${op} ${value.trim()})`; - } - ); - - // Step 2: Replace simple assignments like {{ks}} = val - // Negative lookahead (?!=) ensures == and === are not matched - transformed = transformed.replace( - /\{\{(.*?)\}\}\s*=(?!=)\s*([^;,\n]+)/g, - (_match, keyspec: string, value: string) => { - return `__set(${recordVar}, ${JSON.stringify(keyspec)}, ${value.trim()})`; - } - ); - - // Step 3: Replace remaining {{keyspec}} reads - transformed = transformed.replace( +export function transformCode(code: string, style: "accessor" | "lvalue" = "accessor"): string { + return code.replace( /\{\{(.*?)\}\}/g, (_match, keyspec: string) => { - return `__get(${recordVar}, ${JSON.stringify(keyspec)})`; + const ks = JSON.stringify(keyspec); + if (style === "lvalue") { + return `_f(${ks})`; + } + return `__F[${ks}]`; } ); - - return transformed; } /** @@ -169,18 +155,30 @@ function compileSnippet( // Build argument list: user args + line + filename const allArgNames = [...argNames, "$line", "$filename"]; - // Helper functions available to snippets - const helperCode = ` - const __get = (r, keyspec) => { - const data = typeof r === 'object' && r !== null && 'dataRef' in r ? r.dataRef() : r; - return __findKey(data, '@' + keyspec); - }; - const __set = (r, keyspec, value) => { - const data = typeof r === 'object' && r !== null && 'dataRef' in r ? r.dataRef() : r; - __setKey(data, '@' + keyspec, value); - return value; - }; - `; + // __F is a Proxy that makes {{keyspec}} expansions work as native lvalues. + // {{x}} expands to __F["x"], and the Proxy's get/set traps handle + // KeySpec resolution. This means reads, writes, compound assignments (+=), + // increment (++), etc. all work without any assignment-detection regex. + // + // Only created when the transformed code actually uses __F (i.e. the + // original code contained {{}} templates). Snippets without templates + // (e.g. named snippets with custom args) skip this entirely. + const needsProxy = code.includes("__F["); + const helperCode = needsProxy + ? ` + const __data = typeof r === 'object' && r !== null && 'dataRef' in r ? r.dataRef() : r; + const __F = new Proxy(Object.create(null), { + get(_, prop) { + if (typeof prop === 'string') return __findKey(__data, '@' + prop); + return undefined; + }, + set(_, prop, value) { + if (typeof prop === 'string') __setKey(__data, '@' + prop, value); + return true; + }, + }); + ` + : ""; const fnBody = ` ${helperCode} @@ -192,6 +190,7 @@ function compileSnippet( const factory = new Function( "__findKey", "__setKey", + "Proxy", "state", `return function(${allArgNames.join(", ")}) { ${fnBody} }` ); @@ -199,6 +198,7 @@ function compileSnippet( (data: JsonObject, spec: string) => findKey(data, spec), (data: JsonObject, spec: string, value: JsonValue) => setKey(data, spec, value), + Proxy, state, ); } catch (e) { diff --git a/src/snippets/PerlSnippetRunner.ts b/src/snippets/PerlSnippetRunner.ts index 2d20dc7..67c2e6a 100644 --- a/src/snippets/PerlSnippetRunner.ts +++ b/src/snippets/PerlSnippetRunner.ts @@ -29,7 +29,7 @@ export class PerlSnippetRunner implements SnippetRunner { #mode: SnippetMode = "eval"; async init(code: string, context: SnippetContext): Promise { - this.#code = transformCode(code, "$r"); + this.#code = transformCode(code, "lvalue"); this.#mode = context.mode; } diff --git a/src/snippets/PythonSnippetRunner.ts b/src/snippets/PythonSnippetRunner.ts index 2418bc6..302e048 100644 --- a/src/snippets/PythonSnippetRunner.ts +++ b/src/snippets/PythonSnippetRunner.ts @@ -30,7 +30,7 @@ export class PythonSnippetRunner implements SnippetRunner { #mode: SnippetMode = "eval"; async init(code: string, context: SnippetContext): Promise { - this.#code = transformCode(code, "r"); + this.#code = transformCode(code, "accessor"); this.#mode = context.mode; } diff --git a/src/snippets/perl/runner.pl b/src/snippets/perl/runner.pl index 8b2dee3..e31e08e 100644 --- a/src/snippets/perl/runner.pl +++ b/src/snippets/perl/runner.pl @@ -69,18 +69,41 @@ sub write_message { } # ---------------------------------------------------------------- -# __get / __set helpers for {{}} template expansion +# Lvalue sub for {{}} template expansion # ---------------------------------------------------------------- +# +# {{x}} expands to _f("x") — an lvalue sub that returns the hash +# element directly. This means reads, writes, compound assignments +# (+=, *=, etc.), and even ++ all work natively without any +# assignment-detection regex. $r is set per-record in the main loop. + +my $_current_r; + +sub _f : lvalue { + my ($keyspec) = @_; + my @parts = RecsSDK::_split_keyspec($keyspec); + + my $node = $_current_r; + for my $i (0 .. $#parts - 1) { + my $part = $parts[$i]; + my $next_part = $parts[$i + 1]; + my $next_is_array = ($next_part =~ /^#\d+$/); + + if ($part =~ /^#(\d+)$/) { + $node->[$1] //= ($next_is_array ? [] : {}); + $node = $node->[$1]; + } else { + $node->{$part} //= ($next_is_array ? [] : {}); + $node = $node->{$part}; + } + } -sub __get { - my ($r, $keyspec) = @_; - return RecsSDK::_resolve($r, $keyspec); -} - -sub __set { - my ($r, $keyspec, $value) = @_; - RecsSDK::_set_path($r, $keyspec, $value); - return $value; + my $last = $parts[-1]; + if ($last =~ /^#(\d+)$/) { + $node->[$1]; + } else { + $node->{$last}; + } } # ---------------------------------------------------------------- @@ -153,6 +176,7 @@ sub push_record { $line_num++; my $r = RecsSDK->new($msg->{data}); + $_current_r = $r; eval { if ($mode eq 'eval') { diff --git a/src/snippets/python/runner.py b/src/snippets/python/runner.py index 71dc02c..077108e 100644 --- a/src/snippets/python/runner.py +++ b/src/snippets/python/runner.py @@ -35,6 +35,26 @@ ) +class _FieldAccessor: + """Proxy-like accessor for {{keyspec}} template expansion. + + ``__F["x"]`` reads via KeySpec, ``__F["x"] = v`` writes via KeySpec, + and ``__F["x"] += v`` triggers ``__getitem__`` then ``__setitem__`` — + no assignment-detection regex needed. + """ + + __slots__ = ("_rec",) + + def __init__(self, rec: Record) -> None: + self._rec = rec + + def __getitem__(self, key: str) -> Any: + return self._rec.get("@" + key) + + def __setitem__(self, key: str, value: Any) -> None: + self._rec.set("@" + key, value) + + def _compile_snippet(code: str, mode: str) -> Any: """Compile user code into a code object. @@ -116,12 +136,10 @@ def emit(rec_or_dict: Any) -> None: f"emit() expects a Record or dict, got {type(rec_or_dict).__name__}" ) - def __get(rec: Record, ks: str) -> Any: - return rec.get("@" + ks) - - def __set(rec: Record, ks: str, value: Any) -> Any: - rec.set("@" + ks, value) - return value + # __F is a field accessor that makes {{keyspec}} expansions work as + # native lvalues. {{x}} expands to __F["x"], and Python's + # __getitem__/__setitem__ handle reads, writes, and compound assignments. + __f = _FieldAccessor(r) # Build the snippet namespace namespace: dict[str, Any] = { @@ -131,8 +149,7 @@ def __set(rec: Record, ks: str, value: Any) -> Any: "filename": "NONE", "emit": emit, "Record": Record, - "__get": __get, - "__set": __set, + "__F": __f, # Expose builtins for convenience "json": __import__("json"), "re": __import__("re"), diff --git a/tests/Executor.test.ts b/tests/Executor.test.ts index 4d21dfc..83ca1a3 100644 --- a/tests/Executor.test.ts +++ b/tests/Executor.test.ts @@ -4,141 +4,163 @@ import { Executor, transformCode } from "../src/Executor.ts"; describe("Executor", () => { describe("transformCode", () => { - test("transforms {{key}} to __get call", () => { + test("transforms {{key}} to accessor", () => { const result = transformCode("{{foo}}"); - expect(result).toContain("__get(r,"); - expect(result).toContain('"foo"'); + expect(result).toBe('__F["foo"]'); }); - test("transforms {{key/nested}} to __get call", () => { + test("transforms {{key/nested}} to accessor", () => { const result = transformCode("{{foo/bar}}"); - expect(result).toContain("__get(r,"); - expect(result).toContain('"foo/bar"'); + expect(result).toBe('__F["foo/bar"]'); }); - test("transforms {{key}} = value to __set call", () => { + test("preserves assignment — language handles it natively", () => { const result = transformCode("{{foo}} = 42"); - expect(result).toContain("__set(r,"); - expect(result).toContain('"foo"'); - expect(result).toContain("42"); + expect(result).toBe('__F["foo"] = 42'); }); test("handles multiple transforms", () => { const result = transformCode("{{a}} + {{b}}"); - expect(result).toContain('"a"'); - expect(result).toContain('"b"'); + expect(result).toBe('__F["a"] + __F["b"]'); }); - test("transforms {{key}} += value to compound assignment", () => { + test("preserves compound += — language handles it natively", () => { const result = transformCode("{{x}} += 2"); - expect(result).toBe('__set(r, "x", __get(r, "x") + 2)'); + expect(result).toBe('__F["x"] += 2'); }); - test("transforms {{key}} -= value to compound assignment", () => { + test("preserves compound -= — language handles it natively", () => { const result = transformCode("{{x}} -= 1"); - expect(result).toBe('__set(r, "x", __get(r, "x") - 1)'); + expect(result).toBe('__F["x"] -= 1'); }); - test("transforms {{key}} *= value to compound assignment", () => { + test("preserves compound *= — language handles it natively", () => { const result = transformCode("{{x}} *= 3"); - expect(result).toBe('__set(r, "x", __get(r, "x") * 3)'); + expect(result).toBe('__F["x"] *= 3'); }); - test("transforms {{key}} /= value to compound assignment", () => { + test("preserves compound /= — language handles it natively", () => { const result = transformCode("{{x}} /= 2"); - expect(result).toBe('__set(r, "x", __get(r, "x") / 2)'); + expect(result).toBe('__F["x"] /= 2'); }); - test("transforms {{key}} **= value to compound assignment", () => { + test("preserves compound **= — language handles it natively", () => { const result = transformCode("{{x}} **= 2"); - expect(result).toBe('__set(r, "x", __get(r, "x") ** 2)'); + expect(result).toBe('__F["x"] **= 2'); }); - test("transforms {{key}} ||= value to compound assignment", () => { + test("preserves compound ||= — language handles it natively", () => { const result = transformCode("{{x}} ||= 5"); - expect(result).toBe('__set(r, "x", __get(r, "x") || 5)'); + expect(result).toBe('__F["x"] ||= 5'); }); - test("transforms {{key}} &&= value to compound assignment", () => { + test("preserves compound &&= — language handles it natively", () => { const result = transformCode("{{x}} &&= 5"); - expect(result).toBe('__set(r, "x", __get(r, "x") && 5)'); + expect(result).toBe('__F["x"] &&= 5'); }); - test("transforms {{key}} ??= value to compound assignment", () => { + test("preserves compound ??= — language handles it natively", () => { const result = transformCode("{{x}} ??= 5"); - expect(result).toBe('__set(r, "x", __get(r, "x") ?? 5)'); + expect(result).toBe('__F["x"] ??= 5'); }); - test("transforms {{key}} >>= value to compound assignment", () => { + test("preserves compound >>= — language handles it natively", () => { const result = transformCode("{{x}} >>= 1"); - expect(result).toBe('__set(r, "x", __get(r, "x") >> 1)'); + expect(result).toBe('__F["x"] >>= 1'); }); - test("transforms {{key}} >>>= value to compound assignment", () => { + test("preserves compound >>>= — language handles it natively", () => { const result = transformCode("{{x}} >>>= 1"); - expect(result).toBe('__set(r, "x", __get(r, "x") >>> 1)'); + expect(result).toBe('__F["x"] >>>= 1'); }); - test("transforms {{key}} <<= value to compound assignment", () => { + test("preserves compound <<= — language handles it natively", () => { const result = transformCode("{{x}} <<= 1"); - expect(result).toBe('__set(r, "x", __get(r, "x") << 1)'); + expect(result).toBe('__F["x"] <<= 1'); }); - test("transforms {{key}} |= value to compound assignment", () => { + test("preserves compound |= — language handles it natively", () => { const result = transformCode("{{x}} |= 3"); - expect(result).toBe('__set(r, "x", __get(r, "x") | 3)'); + expect(result).toBe('__F["x"] |= 3'); }); - test("transforms {{key}} &= value to compound assignment", () => { + test("preserves compound &= — language handles it natively", () => { const result = transformCode("{{x}} &= 3"); - expect(result).toBe('__set(r, "x", __get(r, "x") & 3)'); + expect(result).toBe('__F["x"] &= 3'); }); - test("transforms {{key}} ^= value to compound assignment", () => { + test("preserves compound ^= — language handles it natively", () => { const result = transformCode("{{x}} ^= 3"); - expect(result).toBe('__set(r, "x", __get(r, "x") ^ 3)'); + expect(result).toBe('__F["x"] ^= 3'); }); - test("transforms {{key}} //= value to compound assignment", () => { + test("preserves compound //= — language handles it natively", () => { const result = transformCode("{{x}} //= 10"); - expect(result).toBe('__set(r, "x", __get(r, "x") // 10)'); + expect(result).toBe('__F["x"] //= 10'); }); - test("transforms {{key}} %= value to compound assignment", () => { + test("preserves compound %= — language handles it natively", () => { const result = transformCode("{{x}} %= 3"); - expect(result).toBe('__set(r, "x", __get(r, "x") % 3)'); + expect(result).toBe('__F["x"] %= 3'); }); - test("compound assignment with nested keyspec", () => { + test("nested keyspec in compound assignment", () => { const result = transformCode("{{a/b}} += 1"); - expect(result).toBe('__set(r, "a/b", __get(r, "a/b") + 1)'); + expect(result).toBe('__F["a/b"] += 1'); }); - test("uses custom recordVar", () => { - const result = transformCode("{{x}} += 1", "$r"); - expect(result).toBe('__set($r, "x", __get($r, "x") + 1)'); + test("lvalue style for Perl", () => { + const result = transformCode("{{x}} += 1", "lvalue"); + expect(result).toBe('_f("x") += 1'); }); - test("uses custom recordVar for simple assign", () => { - const result = transformCode("{{x}} = 5", "$r"); - expect(result).toBe('__set($r, "x", 5)'); + test("lvalue style for Perl simple assign", () => { + const result = transformCode("{{x}} = 5", "lvalue"); + expect(result).toBe('_f("x") = 5'); }); - test("uses custom recordVar for read", () => { - const result = transformCode("{{x}}", "$r"); - expect(result).toBe('__get($r, "x")'); + test("lvalue style for Perl read", () => { + const result = transformCode("{{x}}", "lvalue"); + expect(result).toBe('_f("x")'); }); - test("does not match == as assignment", () => { + test("== is preserved correctly (no special handling needed)", () => { const result = transformCode("{{x}} == 5"); + expect(result).toBe('__F["x"] == 5'); expect(result).not.toContain("__set"); - expect(result).toContain("__get"); }); - test("does not match === as assignment", () => { + test("=== is preserved correctly (no special handling needed)", () => { const result = transformCode("{{x}} === 5"); - expect(result).not.toContain("__set"); - expect(result).toContain("__get"); + expect(result).toBe('__F["x"] === 5'); + }); + + // --- Regression tests for bugs fixed by lvalue approach --- + + test("assignment with function call containing commas in RHS", () => { + // Old regex approach broke on commas: [^;,\\n]+ stopped at the comma + const result = transformCode("{{x}} = foo(a, b)"); + expect(result).toBe('__F["x"] = foo(a, b)'); + }); + + test("assignment with array literal in RHS", () => { + const result = transformCode("{{x}} = [1, 2, 3]"); + expect(result).toBe('__F["x"] = [1, 2, 3]'); + }); + + test("assignment with object literal in RHS", () => { + const result = transformCode("{{x}} = {a: 1, b: 2}"); + expect(result).toBe('__F["x"] = {a: 1, b: 2}'); + }); + + test("assignment with ternary in RHS", () => { + const result = transformCode("{{x}} = a > b ? c : d"); + expect(result).toBe('__F["x"] = a > b ? c : d'); + }); + + test("compound assignment with function call in RHS", () => { + const result = transformCode("{{x}} += Math.max(a, b)"); + expect(result).toBe('__F["x"] += Math.max(a, b)'); }); }); @@ -168,8 +190,6 @@ describe("Executor", () => { const executor = new Executor("{{new_field}} = 'created'"); const record = new Record({ x: 1 }); executor.executeCode(record); - // The fuzzy matching with @ prefix on "new_field" won't match any existing key, - // so it'll create a new one const data = record.dataRef(); expect(data["new_field"]).toBe("created"); }); @@ -202,6 +222,61 @@ describe("Executor", () => { expect(record.dataRef()["x"]).toBe(8); }); + // --- Regression tests: bugs that existed with the old regex approach --- + + test("assignment with function call containing commas", () => { + const executor = new Executor("{{x}} = Math.max(3, 7)"); + const record = new Record({ x: 0 }); + executor.executeCode(record); + expect(record.dataRef()["x"]).toBe(7); + }); + + test("compound assignment with function call containing commas", () => { + const executor = new Executor("{{x}} += Math.min(10, 20)"); + const record = new Record({ x: 5 }); + executor.executeCode(record); + expect(record.dataRef()["x"]).toBe(15); + }); + + test("assignment with array literal", () => { + const executor = new Executor("{{x}} = [1, 2, 3]"); + const record = new Record({}); + executor.executeCode(record); + expect(record.dataRef()["x"]).toEqual([1, 2, 3]); + }); + + test("assignment with object literal", () => { + const executor = new Executor("{{x}} = {a: 1, b: 2}"); + const record = new Record({}); + executor.executeCode(record); + expect(record.dataRef()["x"]).toEqual({ a: 1, b: 2 }); + }); + + test("assignment with ternary operator", () => { + const executor = new Executor("{{x}} = {{y}} > 5 ? 'big' : 'small'"); + const r1 = new Record({ y: 10 }); + executor.executeCode(r1); + expect(r1.dataRef()["x"]).toBe("big"); + + const r2 = new Record({ y: 2 }); + executor.executeCode(r2); + expect(r2.dataRef()["x"]).toBe("small"); + }); + + test("increment operator ++", () => { + const executor = new Executor("{{x}}++"); + const record = new Record({ x: 5 }); + executor.executeCode(record); + expect(record.dataRef()["x"]).toBe(6); + }); + + test("decrement operator --", () => { + const executor = new Executor("{{x}}--"); + const record = new Record({ x: 5 }); + executor.executeCode(record); + expect(record.dataRef()["x"]).toBe(4); + }); + test("provides $line counter", () => { const executor = new Executor("return $line"); const r = new Record({}); diff --git a/tests/snippets/TemplateExpansion.test.ts b/tests/snippets/TemplateExpansion.test.ts index 2fb37bd..f7c7d5e 100644 --- a/tests/snippets/TemplateExpansion.test.ts +++ b/tests/snippets/TemplateExpansion.test.ts @@ -234,8 +234,8 @@ describe("{{}} method calls on expanded values [python]", () => { test("round({{field}}, N) rounds number", () => { const runner = new PythonSnippetRunner(); - // Use r["key"] = ... to avoid comma in {{}} = value (regex stops at commas) - void runner.init('r["rounded"] = round({{price}}, 2)', { mode: "eval" }); + // With lvalue approach, commas in RHS work correctly + void runner.init("{{rounded}} = round({{price}}, 2)", { mode: "eval" }); const results = runner.executeBatch([new Record({ price: 3.14159 })]); expect(results).toHaveLength(1); @@ -290,8 +290,8 @@ describe("{{}} method calls on expanded values [python]", () => { describe("{{}} method calls on expanded values [perl]", () => { test("sprintf formats {{field}}", () => { const runner = new PerlSnippetRunner(); - // Use $r->{key} = ... to avoid comma in {{}} = value (regex stops at commas) - void runner.init('$r->{formatted} = sprintf("%.2f", {{price}})', { + // With lvalue approach, commas in RHS work correctly + void runner.init('{{formatted}} = sprintf("%.2f", {{price}})', { mode: "eval", }); @@ -727,10 +727,8 @@ describe("record method access [perl]", () => { const results = runner.executeBatch([new Record({ a: 1, b: 2, c: 3 })]); expect(results).toHaveLength(1); - // count itself is added via __set before keys is evaluated, so 3 original + 1 = 4 - // Actually: transformCode expands to __set($r, "count", scalar(keys %$r)) - // __set modifies the hash first (sets "count" to undef), then keys runs - // Let's just check it's a number >= 3 + // _f("count") = scalar(keys %$r) — the lvalue sub creates the "count" key + // before keys() runs, so we may get 3 or 4 depending on evaluation order expect(results[0]!.record!["count"]).toBeGreaterThanOrEqual(3); }); @@ -844,7 +842,7 @@ describe("record method access [perl]", () => { const results = runner.executeBatch([new Record({ a: 1, b: 2, c: 3 })]); expect(results).toHaveLength(1); - // count field is set via __set which modifies the hash before keys() runs + // _f("count") = scalar($r->keys()) — lvalue may create key before keys() runs expect(results[0]!.record!["count"]).toBeGreaterThanOrEqual(3); });