From 7865271e9cd8a2e788a2859c2259833eda01035e Mon Sep 17 00:00:00 2001 From: Piotr Szul Date: Wed, 10 Jun 2026 18:39:32 +1000 Subject: [PATCH] Fix #18: emit parse_path in function-call form for lambda params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The struct backend generated DuckDB SQL that failed to bind on DuckDB 1.4.x for any forEach/forEachOrNull select containing a getReferenceKey() column, e.g.: Binder Error: Referenced column "el" not found in FROM clause! getReferenceKey() expands to a where() predicate whose _splitPath step compiles to `el.parse_path('/')[...]` — a method call with the lambda parameter as the receiver. When that predicate sits in a list_filter nested inside the forEach's list_transform, DuckDB 1.4.x's binder fails to resolve the lambda parameter. It is not a name-shadowing problem (renaming the inner parameter does not help, and reusing the same name with the function-call form works fine); the trigger is specifically a lambda parameter used as a *method-call receiver* inside a nested list_filter predicate. Emitting the equivalent function-call form `parse_path(el, '/')[...]` binds correctly on both 1.4.x and 1.5.x. The non-lambda _splitPath case stays method-chained so flattenSql can join it onto the preceding navigation segment. Verification: - New e2e test reproduces the issue's minimal case (forEachOrNull participant + getReferenceKey(Practitioner)) and runs the generated SQL against the bundled DuckDB 1.4.1 — RED before, GREEN after. - Full suite: 192 pass / 0 fail (was 191; +1 new test). - End-to-end: built the issue's MinimalRepro.json via the CLI and ran the generated SQL on DuckDB 1.4.1 -> id,practitioner_id / enc1,prac1. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ddb-sql-builder.js | 12 +++++- tests/e2e.test.js | 90 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 91 insertions(+), 11 deletions(-) diff --git a/src/ddb-sql-builder.js b/src/ddb-sql-builder.js index 238e1e8..1d2bc11 100644 --- a/src/ddb-sql-builder.js +++ b/src/ddb-sql-builder.js @@ -173,13 +173,21 @@ export function astToSql(node, inLambda, inputType={}) { //non-standard case '_splitPath': + // Use the function-call form `parse_path(el, '/')` rather than the + // method-call form `el.parse_path('/')` when operating on a lambda + // parameter. DuckDB 1.4.x's binder fails to resolve a lambda parameter + // used as a method-call receiver inside a list_filter predicate that is + // itself nested in another lambda (e.g. getReferenceKey() inside a + // forEach) — see issue #18. The function-call form binds correctly on + // both 1.4.x and 1.5.x. The non-lambda case stays method-chained so + // flattenSql can join it onto the preceding navigation segment. return inputType && inputType.isArray ? { - sql: `list_transform(el -> el.parse_path('/')[${firstArg.value}])`, + sql: `list_transform(el -> parse_path(el, '/')[${firstArg.value}])`, outputType: {isArray: true, fhirType: "string"} } : { - sql: `${inLambda ? "el." : ""}parse_path('/')[${firstArg.value}]`, + sql: `parse_path(${inLambda ? "el, " : ""}'/')[${firstArg.value}]`, outputType: {isArray: false, fhirType: "string"} } diff --git a/tests/e2e.test.js b/tests/e2e.test.js index 1d93428..d20608a 100644 --- a/tests/e2e.test.js +++ b/tests/e2e.test.js @@ -8,6 +8,7 @@ import fhirSchema from "../schemas/fhir-schema-r4.json"; let db; let resourceFile; +let encounterFile; const resource = { "resourceType": "QuestionnaireResponse", @@ -19,18 +20,28 @@ const resource = { }] }; -beforeAll(done => { +// Encounter with a participant referencing a Practitioner — drives the issue #18 +// test (a getReferenceKey() column inside a forEachOrNull). +const encounterResource = { + resourceType: "Encounter", + id: "enc1", + status: "finished", + participant: [{individual: {reference: "Practitioner/prac1"}}] +}; + +beforeAll(async () => { db = openMemoryDb(); - // Create temporary resource file + // Create temporary resource files resourceFile = path.join(import.meta.dir, "e2e-test-resources.temp.json"); - Bun.write(resourceFile, JSON.stringify([resource])); - done(); + encounterFile = path.join(import.meta.dir, "e2e-encounter.temp.json"); + await Bun.write(resourceFile, JSON.stringify([resource])); + await Bun.write(encounterFile, JSON.stringify([encounterResource])); }); afterAll(done => { - // Clean up temporary file - if (fs.existsSync(resourceFile)) { - fs.unlinkSync(resourceFile); + // Clean up temporary files + for (const file of [resourceFile, encounterFile]) { + if (file && fs.existsSync(file)) fs.unlinkSync(file); } db.close(() => done()); }); @@ -55,7 +66,68 @@ describe("e2e tests", () => { true, true ); - const result = await executeQuery(db, querySql); + const result = await executeQuery(db, querySql); + expect(new Set(result)).toEqual(new Set(expected)); + }); + + test("forEachOrNull with getReferenceKey() column binds on DuckDB 1.4.x (issue #18)", async () => { + // getReferenceKey() lowers to a where() predicate whose _splitPath step uses the + // lambda parameter as a method-call receiver (el.parse_path(...)). When that + // predicate sits in a list_filter nested inside the forEachOrNull's + // list_transform, DuckDB 1.4.x's binder fails to resolve the parameter: + // Binder Error: Referenced column "el" not found in FROM clause! + // Emitting parse_path in function-call form binds correctly on 1.4.x and 1.5.x. + const viewDefinition = { + "resource": "Encounter", + "select": [ + {"column": [{"name": "id", "path": "getResourceKey()"}]}, + { + "forEachOrNull": "participant", + "column": [{ + "name": "practitioner_id", + "path": "individual.getReferenceKey(Practitioner)" + }] + } + ] + }; + + const expected = [{"id": "enc1", "practitioner_id": "prac1"}]; + const querySql = templateToQuery( + viewDefinition, fhirSchema, + testQueryTemplate, [["test_file_path", encounterFile]], + true, true + ); + + const result = await executeQuery(db, querySql); expect(new Set(result)).toEqual(new Set(expected)); }); -}); \ No newline at end of file + + test("forEach with getReferenceKey() column binds on DuckDB 1.4.x (issue #18)", async () => { + // Same nested-lambda codegen as the forEachOrNull case above; forEach only + // differs by omitting the .ifnull2([NULL]) null-row suffix. Guards both + // directives against regression of the parse_path binding fix. + const viewDefinition = { + "resource": "Encounter", + "select": [ + {"column": [{"name": "id", "path": "getResourceKey()"}]}, + { + "forEach": "participant", + "column": [{ + "name": "practitioner_id", + "path": "individual.getReferenceKey(Practitioner)" + }] + } + ] + }; + + const expected = [{"id": "enc1", "practitioner_id": "prac1"}]; + const querySql = templateToQuery( + viewDefinition, fhirSchema, + testQueryTemplate, [["test_file_path", encounterFile]], + true, true + ); + + const result = await executeQuery(db, querySql); + expect(new Set(result)).toEqual(new Set(expected)); + }); +});