From 2035ca419c1791d42e52c7056ff6c8bef175f16b Mon Sep 17 00:00:00 2001 From: Piotr Szul Date: Thu, 30 Apr 2026 17:54:09 +1000 Subject: [PATCH 1/7] Bump sqlonfhir submodule to repeat-updates and switch to aehrc fork Points the sqlonfhir submodule at the new repeat-updates branch on aehrc/sql-on-fhir-v2 (commit 27ee2c6) which adds twelve test cases expanding repeat-operator coverage. Sets the submodule URL to the aehrc fork so this branch tracks against the staging remote pending the upstream PR to FHIR/sql-on-fhir-v2. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitmodules | 2 +- sqlonfhir | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index 437c0a0..345362e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "sqlonfhir"] path = sqlonfhir - url = git@github.com:FHIR/sql-on-fhir-v2.git + url = git@github.com:aehrc/sql-on-fhir-v2.git diff --git a/sqlonfhir b/sqlonfhir index b60a9c9..27ee2c6 160000 --- a/sqlonfhir +++ b/sqlonfhir @@ -1 +1 @@ -Subproject commit b60a9c90918f0cc8736c13052c6a9625d84a17ba +Subproject commit 27ee2c65442a000b78beef4b4a2ab1517ba3efe4 From e3a87d1cdb2d300a0ef1eca23de5b356ed3c5e41 Mon Sep 17 00:00:00 2001 From: Piotr Szul Date: Fri, 1 May 2026 16:07:50 +1000 Subject: [PATCH 2/7] Refactor query generator to tree-walker architecture Replace the processor-based query generator (ForEachProcessor, RepeatProcessor, SelectClauseBuilder, SelectCombinationExpander) with a new tree-walker compiler in src/queryGenerator/treeWalker/. The new approach walks the ViewDefinition select tree recursively, classifying nodes, generating CTE templates, and rendering T-SQL, making the repeat/forEach nesting logic clearer and easier to extend. Co-Authored-By: Claude Sonnet 4.6 --- src/queryGenerator.ts | 472 +------------ src/queryGenerator/ForEachProcessor.ts | 637 ------------------ src/queryGenerator/RepeatProcessor.ts | 471 ------------- src/queryGenerator/SelectClauseBuilder.ts | 596 ---------------- .../SelectCombinationExpander.ts | 138 ---- src/queryGenerator/index.ts | 7 - .../treeWalker/aliasGenerator.ts | 14 + src/queryGenerator/treeWalker/classify.ts | 19 + src/queryGenerator/treeWalker/compile.ts | 109 +++ src/queryGenerator/treeWalker/cteTemplates.ts | 135 ++++ src/queryGenerator/treeWalker/index.ts | 21 + .../treeWalker/mergeSiblings.ts | 47 ++ .../treeWalker/operators/columnsOnly.ts | 40 ++ .../treeWalker/operators/forEach.ts | 200 ++++++ .../treeWalker/operators/group.ts | 48 ++ .../treeWalker/operators/repeat.ts | 107 +++ .../treeWalker/operators/unionAll.ts | 128 ++++ src/queryGenerator/treeWalker/render.ts | 57 ++ src/queryGenerator/treeWalker/types.ts | 93 +++ src/queryGenerator/treeWalker/walker.ts | 51 ++ 20 files changed, 1086 insertions(+), 2304 deletions(-) delete mode 100644 src/queryGenerator/ForEachProcessor.ts delete mode 100644 src/queryGenerator/RepeatProcessor.ts delete mode 100644 src/queryGenerator/SelectClauseBuilder.ts delete mode 100644 src/queryGenerator/SelectCombinationExpander.ts create mode 100644 src/queryGenerator/treeWalker/aliasGenerator.ts create mode 100644 src/queryGenerator/treeWalker/classify.ts create mode 100644 src/queryGenerator/treeWalker/compile.ts create mode 100644 src/queryGenerator/treeWalker/cteTemplates.ts create mode 100644 src/queryGenerator/treeWalker/index.ts create mode 100644 src/queryGenerator/treeWalker/mergeSiblings.ts create mode 100644 src/queryGenerator/treeWalker/operators/columnsOnly.ts create mode 100644 src/queryGenerator/treeWalker/operators/forEach.ts create mode 100644 src/queryGenerator/treeWalker/operators/group.ts create mode 100644 src/queryGenerator/treeWalker/operators/repeat.ts create mode 100644 src/queryGenerator/treeWalker/operators/unionAll.ts create mode 100644 src/queryGenerator/treeWalker/render.ts create mode 100644 src/queryGenerator/treeWalker/types.ts create mode 100644 src/queryGenerator/treeWalker/walker.ts diff --git a/src/queryGenerator.ts b/src/queryGenerator.ts index 1b8a72b..96c66ba 100644 --- a/src/queryGenerator.ts +++ b/src/queryGenerator.ts @@ -1,26 +1,17 @@ /** * T-SQL query generator for ViewDefinition structures. - * Generates SQL queries that can be executed against MS SQL Server. + * + * Public façade over the tree-walker query compiler. Builds the base + * transpiler context (resource alias, constants, optional test id) and + * delegates SQL generation to `compileViewDefinition`. */ -import { Transpiler, TranspilerContext } from "./fhirpath/transpiler.js"; +import { TranspilerContext } from "./fhirpath/transpiler.js"; +import { compileViewDefinition } from "./queryGenerator/treeWalker/index.js"; import { - ColumnExpressionGenerator, - ForEachProcessor, - PathParser, - RepeatContext, - RepeatProcessor, - SelectClauseBuilder, - SelectCombination, - SelectCombinationExpander, - WhereClauseBuilder, -} from "./queryGenerator/index.js"; -import { - ColumnInfo, TranspilationResult, ViewDefinition, ViewDefinitionConstant, - ViewDefinitionSelect, } from "./types.js"; export interface QueryGeneratorOptions { @@ -31,17 +22,10 @@ export interface QueryGeneratorOptions { } /** - * Main query generator that orchestrates SQL generation from ViewDefinitions. + * Compiles a SQL on FHIR `ViewDefinition` to a T-SQL query. */ export class QueryGenerator { private readonly options: Required; - private readonly pathParser: PathParser; - private readonly combinationExpander: SelectCombinationExpander; - private readonly forEachProcessor: ForEachProcessor; - private readonly repeatProcessor: RepeatProcessor; - private readonly selectClauseBuilder: SelectClauseBuilder; - private readonly whereClauseBuilder: WhereClauseBuilder; - private readonly columnGenerator: ColumnExpressionGenerator; constructor(options: QueryGeneratorOptions = {}) { this.options = { @@ -51,15 +35,6 @@ export class QueryGenerator { resourceJsonColumn: "json", ...options, }; - - // Initialise specialised processors. - this.pathParser = new PathParser(); - this.combinationExpander = new SelectCombinationExpander(); - this.columnGenerator = new ColumnExpressionGenerator(); - this.forEachProcessor = new ForEachProcessor(this.pathParser); - this.repeatProcessor = new RepeatProcessor(); - this.selectClauseBuilder = new SelectClauseBuilder(this.columnGenerator); - this.whereClauseBuilder = new WhereClauseBuilder(); } /** @@ -67,404 +42,20 @@ export class QueryGenerator { */ generateQuery(viewDef: ViewDefinition, testId?: string): TranspilationResult { try { - const context = this.createBaseContext(viewDef, testId); - const columns = this.collectAllColumns(viewDef.select); - const { statements, allCteDefinitions } = - this.generateAllSelectStatements(viewDef, context); - - let sql: string; - if (statements.length > 1) { - // Multiple statements (unionAll). If any have CTEs, consolidate them. - if (allCteDefinitions.length > 0) { - const withClause = `WITH\n${allCteDefinitions.join(",\n")}\n`; - sql = withClause + statements.join("\nUNION ALL\n"); - } else { - sql = statements.join("\nUNION ALL\n"); - } - } else { - // Single statement. If it has CTEs, they're already included. - sql = statements[0]; - } - - return { - sql, - columns, - }; + const transpilerCtx = this.createBaseContext(viewDef, testId); + return compileViewDefinition(viewDef, { + tableName: this.options.tableName, + schemaName: this.options.schemaName, + testId, + transpilerCtx, + }); } catch (error) { throw new Error(`Failed to generate query for ViewDefinition: ${error}`); } } /** - * Result of generating all SELECT statements. - */ - private generateAllSelectStatements( - viewDef: ViewDefinition, - context: TranspilerContext, - ): { statements: string[]; allCteDefinitions: string[] } { - const unionCombinations = this.combinationExpander.expandCombinations( - viewDef.select, - ); - - const statements: string[] = []; - const allCteDefinitions: string[] = []; - const isMultiUnion = unionCombinations.length > 1; - - // Shared counter state to ensure unique CTE aliases across all combinations. - const cteCounter = { value: 0 }; - - for (const combination of unionCombinations) { - const { statement, cteDefinitions } = - this.generateStatementForCombination( - combination, - viewDef, - context, - isMultiUnion, - cteCounter, - ); - statements.push(statement); - allCteDefinitions.push(...cteDefinitions); - } - - return { statements, allCteDefinitions }; - } - - /** - * Generate a complete SQL statement for a specific combination. - * - * Routes to the appropriate statement generator based on the directives - * present in the combination: - * - Repeat statements use recursive CTEs. - * - ForEach statements use CROSS APPLY. - * - Simple statements have neither. - * - * @param combination - The select combination to generate. - * @param viewDef - The ViewDefinition being processed. - * @param context - The transpiler context. - * @param isMultiUnion - If true, CTEs are returned separately for consolidation. - * @param cteCounter - Shared counter for unique CTE aliases across combinations. - * @returns The statement and any CTE definitions (empty if not repeat or single statement). - */ - private generateStatementForCombination( - combination: SelectCombination, - viewDef: ViewDefinition, - context: TranspilerContext, - isMultiUnion: boolean, - cteCounter: { value: number }, - ): { statement: string; cteDefinitions: string[] } { - const hasRepeat = this.repeatProcessor.combinationHasRepeat(combination); - const hasForEach = this.forEachProcessor.combinationHasForEach(combination); - - // Repeat takes precedence if both are present (forEach will be nested). - if (hasRepeat) { - return this.generateRepeatStatement( - combination, - viewDef, - context, - isMultiUnion, - cteCounter, - ); - } else if (hasForEach) { - return { - statement: this.generateForEachStatement(combination, viewDef, context), - cteDefinitions: [], - }; - } else { - return { - statement: this.generateSimpleStatement(combination, viewDef, context), - cteDefinitions: [], - }; - } - } - - /** - * Generate a simple SELECT statement without forEach. - */ - private generateSimpleStatement( - combination: SelectCombination, - viewDef: ViewDefinition, - context: TranspilerContext, - ): string { - const selectClause = this.selectClauseBuilder.generateSimpleSelectClause( - combination, - context, - ); - const fromClause = this.generateFromClause(context); - const whereClause = this.whereClauseBuilder.buildWhereClause( - viewDef.resource, - context.resourceAlias, - context.testId, - viewDef.where, - context, - ); - - let statement = `${selectClause}\n${fromClause}`; - if (whereClause !== null) { - statement += `\n${whereClause}`; - } - - return statement; - } - - /** - * Generate a SELECT statement with forEach using CROSS APPLY. - */ - private generateForEachStatement( - combination: SelectCombination, - viewDef: ViewDefinition, - context: TranspilerContext, - ): string { - const fromClause = this.generateFromClause(context); - const { forEachContextMap, topLevelForEach } = - this.forEachProcessor.buildForEachContextMap( - combination.selects, - context, - combination, - ); - const applyClauses = this.forEachProcessor.buildApplyClauses( - forEachContextMap, - topLevelForEach, - combination, - ); - const selectClause = this.selectClauseBuilder.generateForEachSelectClause( - combination, - context, - forEachContextMap, - ); - const whereClause = this.whereClauseBuilder.buildWhereClause( - viewDef.resource, - context.resourceAlias, - context.testId, - viewDef.where, - context, - ); - - let statement = `${selectClause}\n${fromClause}${applyClauses}`; - if (whereClause !== null) { - statement += `\n${whereClause}`; - } - - return statement; - } - - /** - * Generate a SELECT statement with repeat using recursive CTEs. - * - * The repeat directive generates a recursive CTE that traverses a tree - * structure by following one or more paths at each level. The CTE is then - * joined to the main query using INNER JOIN. - * - * If the combination also contains forEach, the forEach is processed after - * the repeat CTE, using the repeat context as its source. - * - * @param combination - The select combination being processed. - * @param viewDef - The ViewDefinition. - * @param context - The transpiler context. - * @param isMultiUnion - If true, return CTE definitions separately for consolidation. - * @param cteCounter - Shared counter for unique CTE aliases across combinations. - * @returns Statement and CTE definitions. - */ - private generateRepeatStatement( - combination: SelectCombination, - viewDef: ViewDefinition, - context: TranspilerContext, - isMultiUnion: boolean, - cteCounter: { value: number }, - ): { statement: string; cteDefinitions: string[] } { - // Build repeat contexts and CTEs. - const { repeatContextMap, topLevelRepeat } = - this.repeatProcessor.buildRepeatContextMap( - combination.selects, - context, - combination, - cteCounter, - ); - - const tableName = `[${this.options.schemaName}].[${this.options.tableName}]`; - const cteDefinitions = this.repeatProcessor.buildRepeatCteDefinitions( - repeatContextMap, - topLevelRepeat, - context.resourceAlias, - viewDef.resource, - context.testId, - tableName, - ); - - const joinClauses = this.repeatProcessor.buildRepeatApplyClauses( - repeatContextMap, - topLevelRepeat, - context.resourceAlias, - ); - - const { selectClause, forEachApplyClauses } = - this.buildRepeatSelectAndForEach(combination, context, repeatContextMap); - - const fromClause = this.generateFromClause(context); - const statement = this.assembleRepeatStatement( - selectClause, - fromClause, - joinClauses, - forEachApplyClauses, - cteDefinitions, - isMultiUnion, - ); - - return { statement, cteDefinitions: isMultiUnion ? cteDefinitions : [] }; - } - - /** - * Build select clause and forEach apply clauses for repeat statements. - */ - private buildRepeatSelectAndForEach( - combination: SelectCombination, - context: TranspilerContext, - repeatContextMap: Map, - ): { selectClause: string; forEachApplyClauses: string } { - const hasNestedForEach = - this.forEachProcessor.combinationHasForEach(combination); - - if (!hasNestedForEach) { - return { - selectClause: this.selectClauseBuilder.generateRepeatSelectClause( - combination, - context, - repeatContextMap, - ), - forEachApplyClauses: "", - }; - } - - const { forEachContextMap, topLevelForEach } = - this.forEachProcessor.buildForEachContextMap( - combination.selects, - context, - combination, - ); - - this.updateForEachSourcesForRepeat(forEachContextMap, repeatContextMap); - - return { - selectClause: this.selectClauseBuilder.generateRepeatSelectClause( - combination, - context, - repeatContextMap, - forEachContextMap, - ), - forEachApplyClauses: this.forEachProcessor.buildApplyClauses( - forEachContextMap, - topLevelForEach, - combination, - ), - }; - } - - /** - * Assemble the final repeat statement from its components. - */ - private assembleRepeatStatement( - selectClause: string, - fromClause: string, - joinClauses: string, - forEachApplyClauses: string, - cteDefinitions: string[], - isMultiUnion: boolean, - ): string { - const baseStatement = `${selectClause}\n${fromClause}${joinClauses}${forEachApplyClauses}`; - - if (isMultiUnion) { - return baseStatement; - } - - const withClause = - cteDefinitions.length > 0 ? `WITH\n${cteDefinitions.join(",\n")}\n` : ""; - return `${withClause}${baseStatement}`; - } - - /** - * Update forEach source expressions to use repeat CTE instead of resource JSON. - * - * When forEach is nested inside repeat, the forEach should iterate over - * arrays within the repeat context (e.g., repeat_0.item_json) rather than - * the root resource JSON. - */ - private updateForEachSourcesForRepeat( - forEachContextMap: Map, - repeatContextMap: Map, - ): void { - // Find the repeat select that contains each forEach. - for (const [forEachSelect, forEachContext] of forEachContextMap) { - const containingRepeat = this.findContainingRepeat( - forEachSelect, - repeatContextMap, - ); - - if (containingRepeat) { - const repeatContext = repeatContextMap.get(containingRepeat); - if (repeatContext) { - // Update the forEach source to use the repeat CTE's item_json. - forEachContext.forEachSource = `${repeatContext.cteAlias}.item_json`; - } - } - } - } - - /** - * Find the repeat select that contains a given forEach select. - */ - private findContainingRepeat( - forEachSelect: ViewDefinitionSelect, - repeatContextMap: Map, - ): ViewDefinitionSelect | undefined { - // Check each repeat select to see if the forEach is nested within it. - for (const [repeatSelect] of repeatContextMap) { - if (this.isForEachNestedInRepeat(forEachSelect, repeatSelect)) { - return repeatSelect; - } - } - return undefined; - } - - /** - * Check if a forEach select is nested within a repeat select. - */ - private isForEachNestedInRepeat( - forEachSelect: ViewDefinitionSelect, - repeatSelect: ViewDefinitionSelect, - ): boolean { - if (!repeatSelect.select) { - return false; - } - return this.isSelectNestedIn(forEachSelect, repeatSelect.select); - } - - /** - * Recursively check if a select is nested within a list of selects. - */ - private isSelectNestedIn( - target: ViewDefinitionSelect, - selects: ViewDefinitionSelect[], - ): boolean { - for (const select of selects) { - if (select === target) { - return true; - } - if (select.select && this.isSelectNestedIn(target, select.select)) { - return true; - } - } - return false; - } - - /** - * Generate the FROM clause. - */ - private generateFromClause(context: TranspilerContext): string { - const tableName = `[${this.options.schemaName}].[${this.options.tableName}]`; - return `FROM ${tableName} AS [${context.resourceAlias}]`; - } - - /** - * Create the base transpiler context. + * Create the base transpiler context with resource alias and constants. */ private createBaseContext( viewDef: ViewDefinition, @@ -486,7 +77,8 @@ export class QueryGenerator { } /** - * Extract the value from a ViewDefinitionConstant. + * Extract the value from a ViewDefinitionConstant. Throws if zero or more + * than one `value[x]` element is set. */ private getConstantValue( constant: ViewDefinitionConstant, @@ -533,34 +125,4 @@ export class QueryGenerator { const key = definedValues[0]; return constant[key] as string | number | boolean; } - - /** - * Collect all column definitions from select elements. - */ - private collectAllColumns(selects: ViewDefinitionSelect[]): ColumnInfo[] { - const columns: ColumnInfo[] = []; - - for (const select of selects) { - if (select.column) { - for (const column of select.column) { - columns.push({ - name: column.name, - type: Transpiler.inferSqlType(column.type, column.tag), - nullable: true, // FHIR data is generally nullable. - description: column.description, - }); - } - } - - if (select.select) { - columns.push(...this.collectAllColumns(select.select)); - } - - if (select.unionAll) { - columns.push(...this.collectAllColumns(select.unionAll)); - } - } - - return columns; - } } diff --git a/src/queryGenerator/ForEachProcessor.ts b/src/queryGenerator/ForEachProcessor.ts deleted file mode 100644 index dfda1c6..0000000 --- a/src/queryGenerator/ForEachProcessor.ts +++ /dev/null @@ -1,637 +0,0 @@ -/** - * Processes forEach operations and generates CROSS APPLY clauses. - */ - -import { TranspilerContext } from "../fhirpath/transpiler.js"; -import { ViewDefinitionSelect } from "../types.js"; -import { SelectCombination } from "./SelectCombinationExpander.js"; -import { PathParser } from "./PathParser.js"; - -/** - * Counter state for generating unique forEach aliases. - */ -interface CounterState { - value: number; -} - -/** - * Result of building forEach context map. - */ -interface ForEachContextMapResult { - forEachContextMap: Map; - topLevelForEach: ViewDefinitionSelect[]; -} - -/** - * Handles all forEach-related processing and CROSS APPLY generation. - */ -export class ForEachProcessor { - private readonly pathParser: PathParser; - - constructor(pathParser: PathParser) { - this.pathParser = pathParser; - } - - /** - * Check if a specific combination has forEach operations. - */ - combinationHasForEach(combination: SelectCombination): boolean { - for (let i = 0; i < combination.selects.length; i++) { - const select = combination.selects[i]; - const unionChoice = combination.unionChoices[i]; - - if (this.selectHasForEach(select)) { - return true; - } - - // If this select has a unionAll choice, also check the chosen branch. - if (unionChoice >= 0 && select.unionAll?.[unionChoice]) { - const chosenBranch = select.unionAll[unionChoice]; - if (this.selectHasForEach(chosenBranch)) { - return true; - } - } - } - return false; - } - - /** - * Check if a single select has forEach operations (including nested). - */ - selectHasForEach(select: ViewDefinitionSelect): boolean { - if (select.forEach || select.forEachOrNull) { - return true; - } - - if (select.select && this.hasForEachInSelects(select.select)) { - return true; - } - - return !!(select.unionAll && this.unionAllHasForEach(select.unionAll)); - } - - /** - * Check if any select in the tree has forEach operations. - */ - private hasForEachInSelects(selects: ViewDefinitionSelect[]): boolean { - return selects.some((select) => this.selectHasForEach(select)); - } - - /** - * Check if any unionAll option has forEach operations. - */ - private unionAllHasForEach(unionAllOptions: ViewDefinitionSelect[]): boolean { - return unionAllOptions.some( - (unionOption) => - (unionOption.forEach ?? unionOption.forEachOrNull) !== undefined || - (unionOption.select && this.hasForEachInSelects(unionOption.select)), - ); - } - - /** - * Build the forEach context map by generating contexts for all forEach. - */ - buildForEachContextMap( - selects: ViewDefinitionSelect[], - context: TranspilerContext, - combination?: SelectCombination, - ): ForEachContextMapResult { - const forEachContextMap = new Map< - ViewDefinitionSelect, - TranspilerContext - >(); - const counterState: CounterState = { value: 0 }; - - const topLevelForEach = this.collectTopLevelForEach(selects, combination); - - for (const select of topLevelForEach) { - this.generateForEachContexts( - select, - context.resourceAlias + ".json", - context, - forEachContextMap, - counterState, - ); - } - - return { forEachContextMap, topLevelForEach }; - } - - /** - * Collect all forEach that should be treated as top-level. - */ - private collectTopLevelForEach( - selects: ViewDefinitionSelect[], - combination?: SelectCombination, - ): ViewDefinitionSelect[] { - const topLevelForEach: ViewDefinitionSelect[] = []; - - for (const select of selects) { - this.processSelectForEach(select, topLevelForEach); - - // Also process unionAll choices if present and parent doesn't have forEach. - if (combination && !this.isForEachSelect(select)) { - this.processUnionAllChoice(select, combination, topLevelForEach); - } - } - - return topLevelForEach; - } - - /** - * Process a select for forEach, handling both direct forEach and nested selects. - */ - private processSelectForEach( - select: ViewDefinitionSelect, - topLevelForEach: ViewDefinitionSelect[], - ): void { - if (this.isForEachSelect(select)) { - topLevelForEach.push(select); - } else if (select.select) { - this.addForEachFromSelectArray(select.select, topLevelForEach); - } - } - - /** - * Process a select with a unionAll choice from a combination. - */ - private processUnionAllChoice( - select: ViewDefinitionSelect, - combination: SelectCombination, - topLevelForEach: ViewDefinitionSelect[], - ): void { - const selectIndex = combination.selects.indexOf(select); - const unionChoice = - selectIndex >= 0 ? combination.unionChoices[selectIndex] : -1; - - if (unionChoice >= 0 && select.unionAll?.[unionChoice]) { - const chosenBranch = select.unionAll[unionChoice]; - if (this.isForEachSelect(chosenBranch)) { - topLevelForEach.push(chosenBranch); - } else if (chosenBranch.select) { - this.addForEachFromSelectArray(chosenBranch.select, topLevelForEach); - } - } - } - - /** - * Add forEach from a select array to the topLevelForEach list. - */ - private addForEachFromSelectArray( - selects: ViewDefinitionSelect[], - topLevelForEach: ViewDefinitionSelect[], - ): void { - for (const nestedSelect of selects) { - if (this.isForEachSelect(nestedSelect)) { - topLevelForEach.push(nestedSelect); - } - } - } - - /** - * Check if a select is a forEach or forEachOrNull. - */ - private isForEachSelect(select: ViewDefinitionSelect): boolean { - return !!(select.forEach ?? select.forEachOrNull); - } - - /** - * Generate forEach contexts recursively. - */ - private generateForEachContexts( - forEachSelect: ViewDefinitionSelect, - sourceExpression: string, - baseContext: TranspilerContext, - forEachContextMap: Map, - counterState: CounterState, - ): void { - const applyAlias = `forEach_${counterState.value++}`; - const forEachContext = this.createForEachContext( - baseContext, - applyAlias, - sourceExpression, - forEachSelect, - ); - - forEachContextMap.set(forEachSelect, forEachContext); - - this.generateNestedForEachContexts( - forEachSelect, - applyAlias, - forEachContext, - forEachContextMap, - counterState, - ); - } - - /** - * Create a transpiler context specific to a forEach. - */ - private createForEachContext( - baseContext: TranspilerContext, - applyAlias: string, - sourceExpression: string, - forEachSelect: ViewDefinitionSelect, - ): TranspilerContext { - const forEachPath = forEachSelect.forEach ?? forEachSelect.forEachOrNull; - - return { - ...baseContext, - iterationContext: `${applyAlias}.value`, - currentForEachAlias: applyAlias, - forEachSource: sourceExpression, - forEachPath: `$.${forEachPath}`, - }; - } - - /** - * Generate nested forEach contexts within this forEach's select and unionAll options. - */ - private generateNestedForEachContexts( - forEachSelect: ViewDefinitionSelect, - applyAlias: string, - baseContext: TranspilerContext, - forEachContextMap: Map, - counterState: CounterState, - ): void { - if (forEachSelect.select) { - this.generateNestedSelectContexts( - forEachSelect.select, - applyAlias, - baseContext, - forEachContextMap, - counterState, - ); - } - - if (forEachSelect.unionAll) { - this.generateNestedUnionAllContexts( - forEachSelect.unionAll, - applyAlias, - baseContext, - forEachContextMap, - counterState, - ); - } - } - - /** - * Generate forEach contexts for nested selects. - */ - private generateNestedSelectContexts( - nestedSelects: ViewDefinitionSelect[], - applyAlias: string, - forEachContext: TranspilerContext, - forEachContextMap: Map, - counterState: CounterState, - ): void { - for (const nestedSelect of nestedSelects) { - if (this.isForEachSelect(nestedSelect)) { - this.generateForEachContexts( - nestedSelect, - `${applyAlias}.value`, - forEachContext, - forEachContextMap, - counterState, - ); - } - } - } - - /** - * Generate forEach contexts for nested unionAll options. - */ - private generateNestedUnionAllContexts( - unionAllOptions: ViewDefinitionSelect[], - applyAlias: string, - forEachContext: TranspilerContext, - forEachContextMap: Map, - counterState: CounterState, - ): void { - for (const unionOption of unionAllOptions) { - if (this.isForEachSelect(unionOption)) { - this.generateForEachContexts( - unionOption, - `${applyAlias}.value`, - forEachContext, - forEachContextMap, - counterState, - ); - } - } - } - - /** - * Build CROSS APPLY clauses in reverse order for forEach processing. - */ - buildApplyClauses( - forEachContextMap: Map, - topLevelForEach: ViewDefinitionSelect[], - combination: SelectCombination, - ): string { - return [...topLevelForEach] - .reverse() - .map((select) => { - const forEachContext = forEachContextMap.get(select); - if (!forEachContext) { - throw new Error("forEach context not found"); - } - return this.generateForEachClause( - select, - forEachContext, - forEachContextMap, - combination, - ); - }) - .join(""); - } - - /** - * Generate CROSS APPLY clauses for a forEach and its nested forEach. - */ - private generateForEachClause( - forEachSelect: ViewDefinitionSelect, - forEachContext: TranspilerContext, - forEachContextMap: Map, - combination?: SelectCombination, - ): string { - const clause = this.buildApplyClause(forEachSelect, forEachContext); - const nestedSelectClauses = this.processNestedSelectClauses( - forEachSelect, - forEachContextMap, - combination, - ); - const nestedUnionClauses = this.processNestedUnionAllClauses( - forEachSelect, - forEachContextMap, - combination, - ); - - return clause + nestedSelectClauses + nestedUnionClauses; - } - - /** - * Build the APPLY clause for a forEach using its pre-generated context. - */ - private buildApplyClause( - forEachSelect: ViewDefinitionSelect, - forEachContext: TranspilerContext, - ): string { - const rawPath = forEachSelect.forEach ?? forEachSelect.forEachOrNull; - const isOrNull = !!forEachSelect.forEachOrNull; - const applyType = isOrNull ? "OUTER APPLY" : "CROSS APPLY"; - const applyAlias = forEachContext.currentForEachAlias ?? ""; - const sourceExpression = forEachContext.forEachSource ?? ""; - - const { - path: pathWithoutWhere, - whereCondition, - useFirst, - } = this.pathParser.parseFhirPathWhere(rawPath ?? "", forEachContext); - const { path: forEachPath, arrayIndex } = - this.pathParser.parseArrayIndexing(pathWithoutWhere); - const arrayPaths = this.pathParser.detectArrayFlatteningPaths(forEachPath); - - if (arrayPaths.length > 1) { - return this.buildNestedForEachClause( - arrayPaths, - sourceExpression, - applyAlias, - applyType, - arrayIndex, - whereCondition, - ); - } - - return this.buildSimpleApplyClause( - applyType, - sourceExpression, - forEachPath, - applyAlias, - arrayIndex, - whereCondition, - useFirst, - ); - } - - /** - * Build a simple APPLY clause for single array paths. - */ - private buildSimpleApplyClause( - applyType: string, - sourceExpression: string, - forEachPath: string, - applyAlias: string, - arrayIndex: number | null, - whereCondition: string | null, - useFirst: boolean = false, - ): string { - const whereClauses = this.buildWhereClauses(arrayIndex, whereCondition); - - if (whereClauses.length > 0 || useFirst) { - // Build SELECT with WHERE clause and/or TOP 1 for .first() - const topClause = useFirst ? "TOP 1 " : ""; - const orderBy = useFirst ? " ORDER BY [key]" : ""; - const whereClause = - whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : ""; - - return `\n${applyType} ( - SELECT ${topClause}* FROM OPENJSON(${sourceExpression}, '$.${forEachPath}') - ${whereClause}${orderBy} - ) AS ${applyAlias}`; - } - - return `\n${applyType} OPENJSON(${sourceExpression}, '$.${forEachPath}') AS ${applyAlias}`; - } - - /** - * Build WHERE clauses for APPLY operations. - */ - private buildWhereClauses( - arrayIndex: number | null, - whereCondition: string | null, - ): string[] { - const whereClauses: string[] = []; - if (arrayIndex !== null) { - whereClauses.push(`[key] = '${arrayIndex}'`); - } - if (whereCondition !== null) { - whereClauses.push(whereCondition); - } - return whereClauses; - } - - /** - * Build nested CROSS APPLY clauses for array flattening. - */ - private buildNestedForEachClause( - arrayPaths: string[], - sourceExpression: string, - finalAlias: string, - applyType: string, - arrayIndex?: number | null, - whereCondition?: string | null, - ): string { - let clauses = ""; - let currentSource = sourceExpression; - - for (let i = 0; i < arrayPaths.length; i++) { - const isLast = i === arrayPaths.length - 1; - const alias = isLast ? finalAlias : `${finalAlias}_nest${i}`; - - const pathSegment = this.pathParser.extractPathSegment(arrayPaths, i); - const { cleanSegment, segmentIndex } = - this.pathParser.parseSegmentIndexing(pathSegment); - const jsonPath = `$.${cleanSegment}`; - - const whereClauses = this.buildNestedWhereClauses( - isLast, - segmentIndex, - arrayIndex, - whereCondition, - ); - - clauses += this.buildApplyWithOptionalWhere( - applyType, - currentSource, - jsonPath, - alias, - whereClauses, - ); - - currentSource = `${alias}.value`; - } - - return clauses; - } - - /** - * Build WHERE clause conditions for nested array filtering. - */ - private buildNestedWhereClauses( - isLast: boolean, - segmentIndex: number | null, - arrayIndex: number | null | undefined, - whereCondition: string | null | undefined, - ): string[] { - const whereClauses: string[] = []; - - if (segmentIndex !== null) { - whereClauses.push(`[key] = '${segmentIndex}'`); - } else if (isLast && arrayIndex !== null && arrayIndex !== undefined) { - whereClauses.push(`[key] = '${arrayIndex}'`); - } - - if (isLast && whereCondition !== null && whereCondition !== undefined) { - whereClauses.push(whereCondition); - } - - return whereClauses; - } - - /** - * Build APPLY clause with optional WHERE conditions. - */ - private buildApplyWithOptionalWhere( - applyType: string, - source: string, - jsonPath: string, - alias: string, - whereClauses: string[], - ): string { - if (whereClauses.length > 0) { - return `\n${applyType} ( - SELECT * FROM OPENJSON(${source}, '${jsonPath}') - WHERE ${whereClauses.join(" AND ")} - ) AS ${alias}`; - } - return `\n${applyType} OPENJSON(${source}, '${jsonPath}') AS ${alias}`; - } - - /** - * Process nested forEach within this forEach's select. - */ - private processNestedSelectClauses( - forEachSelect: ViewDefinitionSelect, - forEachContextMap: Map, - combination?: SelectCombination, - ): string { - if (!forEachSelect.select) { - return ""; - } - - return forEachSelect.select - .filter((nestedSelect) => this.isForEachSelect(nestedSelect)) - .map((nestedSelect) => { - const nestedContext = forEachContextMap.get(nestedSelect); - if (!nestedContext) { - throw new Error("Nested forEach context not found"); - } - return this.generateForEachClause( - nestedSelect, - nestedContext, - forEachContextMap, - combination, - ); - }) - .join(""); - } - - /** - * Process nested forEach within this forEach's unionAll options. - */ - private processNestedUnionAllClauses( - forEachSelect: ViewDefinitionSelect, - forEachContextMap: Map, - combination?: SelectCombination, - ): string { - if (!forEachSelect.unionAll || !combination) { - return ""; - } - - const selectedUnionOption = this.getSelectedUnionOption( - forEachSelect, - combination, - ); - if (!selectedUnionOption || !this.isForEachSelect(selectedUnionOption)) { - return ""; - } - - const nestedContext = forEachContextMap.get(selectedUnionOption); - if (!nestedContext) { - return ""; - } - - return this.generateForEachClause( - selectedUnionOption, - nestedContext, - forEachContextMap, - combination, - ); - } - - /** - * Get the selected unionAll option for a forEach in a combination. - */ - private getSelectedUnionOption( - forEachSelect: ViewDefinitionSelect, - combination: SelectCombination, - ): ViewDefinitionSelect | null { - if (!forEachSelect.unionAll) { - return null; - } - - const selectIndex = combination.selects.indexOf(forEachSelect); - const selectedUnionIndex = - selectIndex >= 0 ? combination.unionChoices[selectIndex] : -1; - - if ( - selectedUnionIndex < 0 || - selectedUnionIndex >= forEachSelect.unionAll.length - ) { - return null; - } - - return forEachSelect.unionAll[selectedUnionIndex]; - } -} diff --git a/src/queryGenerator/RepeatProcessor.ts b/src/queryGenerator/RepeatProcessor.ts deleted file mode 100644 index 9bb99ec..0000000 --- a/src/queryGenerator/RepeatProcessor.ts +++ /dev/null @@ -1,471 +0,0 @@ -/** - * Processes repeat operations and generates recursive CTEs. - * - * The repeat directive recursively traverses a tree structure by following - * one or more FHIRPath expressions at each level. This is implemented using - * SQL Server recursive Common Table Expressions (CTEs). - * - * @author John Grimes - */ - -import type { TranspilerContext } from "../fhirpath/transpiler.js"; -import { ViewDefinitionSelect } from "../types.js"; -import { SelectCombination } from "./SelectCombinationExpander.js"; - -/** - * Counter state for generating unique repeat CTE aliases. - */ -interface CounterState { - value: number; -} - -/** - * Context specific to a repeat operation. - */ -export interface RepeatContext { - /** Unique alias for the CTE (e.g., "repeat_0"). */ - cteAlias: string; - /** FHIRPath expressions to follow recursively. */ - paths: string[]; - /** JSON source expression for the anchor member. */ - sourceExpression: string; - /** The transpiler context for columns within this repeat. */ - transpilerContext: TranspilerContext; -} - -/** - * Result of building repeat context map. - */ -export interface RepeatContextMapResult { - repeatContextMap: Map; - topLevelRepeat: ViewDefinitionSelect[]; -} - -/** - * Handles all repeat-related processing and recursive CTE generation. - * - * The repeat directive works similarly to forEach but traverses tree structures - * of arbitrary depth. For example, `repeat: ["item"]` follows the `item` array - * at each level until no more items exist. - * - * Multiple paths can be specified (e.g., `["item", "answer.item"]`) to follow - * different traversal patterns at each level, with results unioned together. - */ -export class RepeatProcessor { - /** - * Check if a specific combination has repeat operations. - * - * @param combination - The select combination to check. - * @returns True if any select in the combination has repeat. - */ - combinationHasRepeat(combination: SelectCombination): boolean { - for (let i = 0; i < combination.selects.length; i++) { - const select = combination.selects[i]; - const unionChoice = combination.unionChoices[i]; - - if (this.selectHasRepeat(select)) { - return true; - } - - // If this select has a unionAll choice, also check the chosen branch. - if (unionChoice >= 0 && select.unionAll?.[unionChoice]) { - const chosenBranch = select.unionAll[unionChoice]; - if (this.selectHasRepeat(chosenBranch)) { - return true; - } - } - } - return false; - } - - /** - * Check if a single select has repeat operations (including nested). - * - * @param select - The select element to check. - * @returns True if the select or any nested select has repeat. - */ - selectHasRepeat(select: ViewDefinitionSelect): boolean { - if (this.isRepeatSelect(select)) { - return true; - } - - if (select.select && this.hasRepeatInSelects(select.select)) { - return true; - } - - return !!(select.unionAll && this.unionAllHasRepeat(select.unionAll)); - } - - /** - * Check if any select in the array has repeat operations. - */ - private hasRepeatInSelects(selects: ViewDefinitionSelect[]): boolean { - return selects.some((select) => this.selectHasRepeat(select)); - } - - /** - * Check if any unionAll option has repeat operations. - */ - private unionAllHasRepeat(unionAllOptions: ViewDefinitionSelect[]): boolean { - return unionAllOptions.some( - (unionOption) => - this.isRepeatSelect(unionOption) || - (unionOption.select && this.hasRepeatInSelects(unionOption.select)), - ); - } - - /** - * Check if a select is a repeat select. - */ - isRepeatSelect(select: ViewDefinitionSelect): boolean { - return !!(select.repeat && select.repeat.length > 0); - } - - /** - * Build the repeat context map by generating contexts for all repeat operations. - * - * @param selects - The top-level select elements. - * @param context - The base transpiler context. - * @param combination - Optional combination for unionAll handling. - * @param externalCounter - Optional external counter for shared CTE aliases across combinations. - * @returns Map of repeat selects to their contexts and list of top-level repeats. - */ - buildRepeatContextMap( - selects: ViewDefinitionSelect[], - context: TranspilerContext, - combination?: SelectCombination, - externalCounter?: CounterState, - ): RepeatContextMapResult { - const repeatContextMap = new Map(); - // Use external counter if provided, otherwise create a local one. - const counterState: CounterState = externalCounter ?? { value: 0 }; - - const topLevelRepeat = this.collectTopLevelRepeat(selects, combination); - - for (const select of topLevelRepeat) { - this.generateRepeatContext( - select, - context.resourceAlias + ".json", - context, - repeatContextMap, - counterState, - ); - } - - return { repeatContextMap, topLevelRepeat }; - } - - /** - * Collect all repeat selects that should be treated as top-level. - */ - private collectTopLevelRepeat( - selects: ViewDefinitionSelect[], - combination?: SelectCombination, - ): ViewDefinitionSelect[] { - const topLevelRepeat: ViewDefinitionSelect[] = []; - - for (const select of selects) { - this.processSelectRepeat(select, topLevelRepeat); - - // Also process unionAll choices if present and parent doesn't have repeat. - if (combination && !this.isRepeatSelect(select)) { - this.processUnionAllChoice(select, combination, topLevelRepeat); - } - } - - return topLevelRepeat; - } - - /** - * Process a select for repeat, handling both direct repeat and nested selects. - */ - private processSelectRepeat( - select: ViewDefinitionSelect, - topLevelRepeat: ViewDefinitionSelect[], - ): void { - if (this.isRepeatSelect(select)) { - topLevelRepeat.push(select); - } else if (select.select) { - this.addRepeatFromSelectArray(select.select, topLevelRepeat); - } - } - - /** - * Process a select with a unionAll choice from a combination. - */ - private processUnionAllChoice( - select: ViewDefinitionSelect, - combination: SelectCombination, - topLevelRepeat: ViewDefinitionSelect[], - ): void { - const selectIndex = combination.selects.indexOf(select); - const unionChoice = - selectIndex >= 0 ? combination.unionChoices[selectIndex] : -1; - - if (unionChoice >= 0 && select.unionAll?.[unionChoice]) { - const chosenBranch = select.unionAll[unionChoice]; - if (this.isRepeatSelect(chosenBranch)) { - topLevelRepeat.push(chosenBranch); - } else if (chosenBranch.select) { - this.addRepeatFromSelectArray(chosenBranch.select, topLevelRepeat); - } - } - } - - /** - * Add repeat selects from a select array to the topLevelRepeat list. - */ - private addRepeatFromSelectArray( - selects: ViewDefinitionSelect[], - topLevelRepeat: ViewDefinitionSelect[], - ): void { - for (const nestedSelect of selects) { - if (this.isRepeatSelect(nestedSelect)) { - topLevelRepeat.push(nestedSelect); - } - } - } - - /** - * Generate repeat context for a single repeat select. - */ - private generateRepeatContext( - repeatSelect: ViewDefinitionSelect, - sourceExpression: string, - baseContext: TranspilerContext, - repeatContextMap: Map, - counterState: CounterState, - ): void { - const cteAlias = `repeat_${counterState.value++}`; - const paths = repeatSelect.repeat ?? []; - - const repeatContext: RepeatContext = { - cteAlias, - paths, - sourceExpression, - transpilerContext: { - ...baseContext, - // The iteration context points to the CTE's item_json column. - iterationContext: `${cteAlias}.item_json`, - currentForEachAlias: cteAlias, - forEachSource: sourceExpression, - forEachPath: paths.join(", "), - }, - }; - - repeatContextMap.set(repeatSelect, repeatContext); - } - - /** - * Generate CTE definitions as an array for repeat operations. - * - * This method returns the CTE definitions without the WITH keyword, - * allowing them to be consolidated when multiple unionAll branches - * each have their own repeat CTEs. - * - * @param repeatContextMap - Map of repeat selects to their contexts. - * @param topLevelRepeat - List of top-level repeat selects. - * @param resourceAlias - The alias for the resource table (e.g., "r"). - * @param resourceType - The FHIR resource type. - * @param testId - Optional test ID for filtering. - * @param tableName - The fully qualified table name (e.g., "[dbo].[fhir_resources]"). - * @returns Array of CTE definition strings (without WITH keyword). - */ - buildRepeatCteDefinitions( - repeatContextMap: Map, - topLevelRepeat: ViewDefinitionSelect[], - resourceAlias: string, - resourceType: string, - testId?: string, - tableName: string = "[dbo].[fhir_resources]", - ): string[] { - if (topLevelRepeat.length === 0) { - return []; - } - - const cteDefinitions: string[] = []; - - for (const select of topLevelRepeat) { - const repeatContext = repeatContextMap.get(select); - if (!repeatContext) { - throw new Error("Repeat context not found for select."); - } - - const cteDef = this.generateSingleCte( - repeatContext, - resourceAlias, - resourceType, - testId, - tableName, - ); - cteDefinitions.push(cteDef); - } - - return cteDefinitions; - } - - /** - * Generate a single recursive CTE definition. - * - * The CTE has the following structure: - * - Anchor member: Selects initial items using the FIRST path from the root. - * Only the first path is used for the anchor because subsequent paths - * (like `answer.item`) represent traversal patterns that should only be - * followed during recursion, not at the root level. - * - Recursive member: For each path in repeat, follows that path from the - * current level and unions all results. - * - * The CTE columns are: - * - resource_id: Links back to the source resource. - * - item_json: The JSON content of the current item (used for column extraction). - * - depth: Recursion depth (used to prevent infinite loops). - */ - private generateSingleCte( - repeatContext: RepeatContext, - resourceAlias: string, - resourceType: string, - testId?: string, - tableName: string = "[dbo].[fhir_resources]", - ): string { - const { cteAlias, paths, sourceExpression } = repeatContext; - - // Build anchor member using only the FIRST path. - // The anchor represents the entry point into the tree structure. - // For example, with `repeat: ["item", "answer.item"]`, the anchor uses - // `$.item` to get the root items. The `answer.item` path is only followed - // during recursion when traversing from an item that has answers. - const firstPath = paths[0]; - const anchorJsonPath = this.buildJsonPath(firstPath); - const anchorSql = `SELECT - [${resourceAlias}].[id] AS resource_id, - anchor.value AS item_json, - 0 AS depth - FROM ${tableName} AS [${resourceAlias}] - CROSS APPLY OPENJSON(${sourceExpression}, '${anchorJsonPath}') AS anchor - WHERE [${resourceAlias}].[resource_type] = '${resourceType}'${this.buildTestIdCondition(resourceAlias, testId)}`; - - // Build recursive member: for each path, follows that path from current item. - // All paths are used during recursion to traverse the tree structure. - // Multi-segment paths like "answer.item" require nested CROSS APPLY. - const recursiveMembers = paths.map((path, index) => { - return this.buildRecursiveMember(path, cteAlias, index); - }); - - const recursiveSql = recursiveMembers.join("\n UNION ALL\n"); - - return `${cteAlias} AS ( - ${anchorSql} - UNION ALL - ${recursiveSql} -)`; - } - - /** - * Build a recursive member for a single path. - * - * Multi-segment paths like "answer.item" require nested CROSS APPLY clauses - * to traverse through each array. For example, "answer.item" means: - * 1. Iterate over the `answer` array - * 2. For each answer, iterate over the `item` array within it - * - * @param path - The FHIRPath expression (e.g., "item" or "answer.item"). - * @param cteAlias - The alias of the recursive CTE. - * @param index - Index of this path for alias generation. - * @returns SQL fragment for the recursive member. - */ - private buildRecursiveMember( - path: string, - cteAlias: string, - index: number, - ): string { - const segments = path.split("."); - - if (segments.length === 1) { - // Simple path: single CROSS APPLY. - const jsonPath = `$.${segments[0]}`; - const alias = `child_${index}`; - return `SELECT - cte.resource_id, - ${alias}.value AS item_json, - cte.depth + 1 - FROM ${cteAlias} AS cte - CROSS APPLY OPENJSON(cte.item_json, '${jsonPath}') AS ${alias}`; - } - - // Multi-segment path: nested CROSS APPLY for each segment. - // Build from inside out: the last segment is the final result. - let crossApplies = ""; - let currentSource = "cte.item_json"; - - for (let i = 0; i < segments.length; i++) { - const segment = segments[i]; - const alias = `child_${index}_${i}`; - crossApplies += `\n CROSS APPLY OPENJSON(${currentSource}, '$.${segment}') AS ${alias}`; - currentSource = `${alias}.value`; - } - - const finalAlias = `child_${index}_${segments.length - 1}`; - return `SELECT - cte.resource_id, - ${finalAlias}.value AS item_json, - cte.depth + 1 - FROM ${cteAlias} AS cte${crossApplies}`; - } - - /** - * Build a JSON path from a FHIRPath expression. - * Handles simple paths and dot-separated paths like "answer.item". - */ - private buildJsonPath(fhirPath: string): string { - // Convert FHIRPath to JSON path (simple case: just prefix with $.). - return `$.${fhirPath}`; - } - - /** - * Build the test ID condition for filtering test data. - * - * @param resourceAlias - The alias for the resource table. - * @param testId - Optional test ID for filtering. - * @returns SQL condition string or empty string if no testId. - */ - private buildTestIdCondition(resourceAlias: string, testId?: string): string { - if (!testId) { - return ""; - } - return `\n AND [${resourceAlias}].[test_id] = '${testId}'`; - } - - /** - * Build CROSS APPLY clause to join CTE results to the main query. - * - * @param repeatContextMap - Map of repeat selects to their contexts. - * @param topLevelRepeat - List of top-level repeat selects. - * @param resourceAlias - The alias for the resource table. - * @returns The CROSS APPLY clause(s) for joining CTE results. - */ - buildRepeatApplyClauses( - repeatContextMap: Map, - topLevelRepeat: ViewDefinitionSelect[], - resourceAlias: string, - ): string { - if (topLevelRepeat.length === 0) { - return ""; - } - - const applyClauses: string[] = []; - - for (const select of topLevelRepeat) { - const repeatContext = repeatContextMap.get(select); - if (!repeatContext) { - throw new Error("Repeat context not found for select."); - } - - // Join the CTE to the resource table by resource_id. - applyClauses.push( - `\nINNER JOIN ${repeatContext.cteAlias} ON ${repeatContext.cteAlias}.resource_id = [${resourceAlias}].[id]`, - ); - } - - return applyClauses.join(""); - } -} diff --git a/src/queryGenerator/SelectClauseBuilder.ts b/src/queryGenerator/SelectClauseBuilder.ts deleted file mode 100644 index b505ebd..0000000 --- a/src/queryGenerator/SelectClauseBuilder.ts +++ /dev/null @@ -1,596 +0,0 @@ -/** - * Builds SELECT clauses for SQL queries. - */ - -import { TranspilerContext } from "../fhirpath/transpiler.js"; -import { ViewDefinitionColumn, ViewDefinitionSelect } from "../types.js"; -import { SelectCombination } from "./SelectCombinationExpander.js"; -import { ColumnExpressionGenerator } from "./ColumnExpressionGenerator.js"; -import { RepeatContext } from "./RepeatProcessor.js"; - -/** - * Handles generation of SELECT clauses. - */ -export class SelectClauseBuilder { - private readonly columnGenerator: ColumnExpressionGenerator; - - constructor(columnGenerator: ColumnExpressionGenerator) { - this.columnGenerator = columnGenerator; - } - - /** - * Generate SELECT clause for a simple (non-forEach) statement. - */ - generateSimpleSelectClause( - combination: SelectCombination, - context: TranspilerContext, - ): string { - const columnParts: string[] = []; - - for (let i = 0; i < combination.selects.length; i++) { - const select = combination.selects[i]; - const unionChoice = combination.unionChoices[i]; - - this.addSelectElementColumns(select, columnParts, context); - this.addUnionAllColumns(select, unionChoice, columnParts, context); - } - - return `SELECT\n ${columnParts.join(",\n ")}`; - } - - /** - * Generate SELECT clause specifically for forEach statements. - */ - generateForEachSelectClause( - combination: SelectCombination, - context: TranspilerContext, - forEachContextMap: Map, - ): string { - const columnParts: string[] = []; - - for (let i = 0; i < combination.selects.length; i++) { - const select = combination.selects[i]; - const unionChoice = combination.unionChoices[i]; - - if (this.isForEachSelect(select)) { - this.addForEachSelectColumns( - select, - unionChoice, - columnParts, - forEachContextMap, - ); - } else { - this.addNonForEachSelectColumns( - select, - unionChoice, - columnParts, - context, - forEachContextMap, - ); - } - } - - return `SELECT\n ${columnParts.join(",\n ")}`; - } - - /** - * Generate SELECT clause for repeat statements. - * - * For repeat selects, columns are extracted from the CTE's item_json column. - * Non-repeat columns use the base context (resource JSON). - * If forEach is also present, those columns use the forEach context. - * - * @param combination - The select combination being processed. - * @param context - The base transpiler context. - * @param repeatContextMap - Map of repeat selects to their contexts. - * @param forEachContextMap - Optional map of forEach selects to their contexts. - * @returns The generated SELECT clause. - */ - generateRepeatSelectClause( - combination: SelectCombination, - context: TranspilerContext, - repeatContextMap: Map, - forEachContextMap?: Map, - ): string { - const columnParts: string[] = []; - - for (let i = 0; i < combination.selects.length; i++) { - const select = combination.selects[i]; - const unionChoice = combination.unionChoices[i]; - - if (this.isRepeatSelect(select)) { - this.addRepeatSelectColumns( - select, - unionChoice, - columnParts, - repeatContextMap, - forEachContextMap, - ); - } else if (this.isForEachSelect(select) && forEachContextMap) { - this.addForEachSelectColumns( - select, - unionChoice, - columnParts, - forEachContextMap, - ); - } else { - this.addNonRepeatSelectColumns( - select, - unionChoice, - columnParts, - context, - repeatContextMap, - forEachContextMap, - ); - } - } - - return `SELECT\n ${columnParts.join(",\n ")}`; - } - - /** - * Add columns for a repeat select. - * - * Columns within the repeat use the repeat context (CTE item_json). - * Nested forEach within the repeat use their forEach context. - */ - private addRepeatSelectColumns( - select: ViewDefinitionSelect, - unionChoice: number, - columnParts: string[], - repeatContextMap: Map, - forEachContextMap?: Map, - ): void { - const repeatContext = repeatContextMap.get(select); - if (!repeatContext) { - return; - } - - // Add columns from the repeat select using its transpiler context. - if (select.column) { - this.addColumnsToList( - select.column, - columnParts, - repeatContext.transpilerContext, - ); - } - - this.addNestedSelectColumnsForRepeat( - select, - columnParts, - repeatContext.transpilerContext, - forEachContextMap, - ); - - this.addRepeatUnionAllColumns( - select, - unionChoice, - columnParts, - repeatContext.transpilerContext, - repeatContextMap, - ); - } - - /** - * Add nested select columns for a repeat select. - * - * Nested forEach selects use their own context, while other nested selects - * use the repeat context. - */ - private addNestedSelectColumnsForRepeat( - select: ViewDefinitionSelect, - columnParts: string[], - repeatTranspilerContext: TranspilerContext, - forEachContextMap?: Map, - ): void { - if (!select.select) { - return; - } - - for (const nestedSelect of select.select) { - if (this.isForEachSelect(nestedSelect) && forEachContextMap) { - // Nested forEach uses its own context (source updated to use repeat CTE). - const nestedContext = forEachContextMap.get(nestedSelect); - if (nestedContext && nestedSelect.column) { - this.addColumnsToList( - nestedSelect.column, - columnParts, - nestedContext, - ); - } - } else if (nestedSelect.column) { - // Non-forEach nested selects use the repeat context. - this.addColumnsToList( - nestedSelect.column, - columnParts, - repeatTranspilerContext, - ); - } - } - } - - /** - * Add columns for unionAll branches within a repeat select. - */ - private addRepeatUnionAllColumns( - select: ViewDefinitionSelect, - unionChoice: number, - columnParts: string[], - defaultContext: TranspilerContext, - repeatContextMap: Map, - ): void { - if (unionChoice < 0 || !select.unionAll?.[unionChoice]) { - return; - } - - const chosenBranch = select.unionAll[unionChoice]; - if (!chosenBranch.column) { - return; - } - - // Check if the chosen branch is also a repeat. - const branchRepeatContext = repeatContextMap.get(chosenBranch); - const branchContext = branchRepeatContext - ? branchRepeatContext.transpilerContext - : defaultContext; - - this.addColumnsToList(chosenBranch.column, columnParts, branchContext); - } - - /** - * Add columns for a non-repeat select (when repeat is present elsewhere). - */ - private addNonRepeatSelectColumns( - select: ViewDefinitionSelect, - unionChoice: number, - columnParts: string[], - context: TranspilerContext, - repeatContextMap: Map, - forEachContextMap?: Map, - ): void { - if (select.column) { - this.addColumnsToList(select.column, columnParts, context); - } - - // Handle nested selects. - if (select.select) { - for (const nestedSelect of select.select) { - if (this.isRepeatSelect(nestedSelect)) { - this.addRepeatSelectColumns( - nestedSelect, - -1, - columnParts, - repeatContextMap, - forEachContextMap, - ); - } else if (this.isForEachSelect(nestedSelect) && forEachContextMap) { - const nestedContext = forEachContextMap.get(nestedSelect); - if (nestedContext && nestedSelect.column) { - this.addColumnsToList( - nestedSelect.column, - columnParts, - nestedContext, - ); - } - } else if (nestedSelect.column) { - this.addColumnsToList(nestedSelect.column, columnParts, context); - } - } - } - - // Handle unionAll within non-repeat select. - this.addUnionAllColumnsForRepeatContext( - select, - unionChoice, - columnParts, - context, - repeatContextMap, - forEachContextMap, - ); - } - - /** - * Add unionAll columns when repeat context is available. - */ - private addUnionAllColumnsForRepeatContext( - select: ViewDefinitionSelect, - unionChoice: number, - columnParts: string[], - defaultContext: TranspilerContext, - repeatContextMap: Map, - forEachContextMap?: Map, - ): void { - if (unionChoice < 0 || !select.unionAll?.[unionChoice]) { - return; - } - - const chosenBranch = select.unionAll[unionChoice]; - if (!chosenBranch.column) { - return; - } - - // Determine context based on directive type. - let branchContext: TranspilerContext = defaultContext; - - if (this.isRepeatSelect(chosenBranch)) { - const repeatContext = repeatContextMap.get(chosenBranch); - if (repeatContext) { - branchContext = repeatContext.transpilerContext; - } - } else if (this.isForEachSelect(chosenBranch) && forEachContextMap) { - const forEachContext = forEachContextMap.get(chosenBranch); - if (forEachContext) { - branchContext = forEachContext; - } - } - - this.addColumnsToList(chosenBranch.column, columnParts, branchContext); - } - - /** - * Check if a select is a repeat select. - */ - private isRepeatSelect(select: ViewDefinitionSelect): boolean { - return !!(select.repeat && select.repeat.length > 0); - } - - /** - * Add columns from a select element to the column parts array. - */ - private addSelectElementColumns( - select: ViewDefinitionSelect, - columnParts: string[], - context: TranspilerContext, - ): void { - // Skip forEach selects - handled separately. - if (this.isForEachSelect(select)) { - return; - } - - if (select.column) { - this.addColumnsToList(select.column, columnParts, context); - } - - if (select.select) { - for (const nestedSelect of select.select) { - const nestedColumns = this.generateSelectElementColumns( - nestedSelect, - context, - ); - columnParts.push(...nestedColumns); - } - } - } - - /** - * Generate column expressions for a select element (used for nested selects). - */ - private generateSelectElementColumns( - select: ViewDefinitionSelect, - context: TranspilerContext, - ): string[] { - const columnParts: string[] = []; - - if (select.column) { - this.addColumnsToList(select.column, columnParts, context); - } - - if (select.select) { - for (const nestedSelect of select.select) { - const nestedColumns = this.generateSelectElementColumns( - nestedSelect, - context, - ); - columnParts.push(...nestedColumns); - } - } - - return columnParts; - } - - /** - * Add unionAll columns for the chosen combination. - */ - private addUnionAllColumns( - select: ViewDefinitionSelect, - unionChoice: number, - columnParts: string[], - context: TranspilerContext, - forEachContextMap?: Map, - ): void { - if ( - !select.unionAll || - unionChoice < 0 || - unionChoice >= select.unionAll.length - ) { - return; - } - - const chosenUnion = select.unionAll[unionChoice]; - - if (this.isForEachSelect(chosenUnion) && forEachContextMap) { - const unionForEachContext = forEachContextMap.get(chosenUnion); - if (unionForEachContext && chosenUnion.column) { - this.addColumnsToList( - chosenUnion.column, - columnParts, - unionForEachContext, - ); - } - } else if (chosenUnion.column) { - this.addColumnsToList(chosenUnion.column, columnParts, context); - } - } - - /** - * Add columns for a forEach select. - */ - private addForEachSelectColumns( - select: ViewDefinitionSelect, - unionChoice: number, - columnParts: string[], - forEachContextMap: Map, - ): void { - const forEachContext = forEachContextMap.get(select); - if (!forEachContext) { - return; - } - - if (select.column) { - this.addColumnsToList(select.column, columnParts, forEachContext); - } - - this.addNestedSelectColumnsForForEach( - select, - columnParts, - forEachContext, - forEachContextMap, - ); - this.addUnionAllColumnsForSelect( - select, - unionChoice, - columnParts, - forEachContext, - forEachContextMap, - ); - } - - /** - * Add columns for a non-forEach select. - */ - private addNonForEachSelectColumns( - select: ViewDefinitionSelect, - unionChoice: number, - columnParts: string[], - context: TranspilerContext, - forEachContextMap: Map, - ): void { - if (select.column) { - this.addColumnsToList(select.column, columnParts, context); - } - - this.addNestedSelectColumnsForNonForEach( - select, - columnParts, - context, - forEachContextMap, - ); - this.addUnionAllColumnsForSelect( - select, - unionChoice, - columnParts, - context, - forEachContextMap, - ); - } - - /** - * Add nested select columns for forEach select. - */ - private addNestedSelectColumnsForForEach( - select: ViewDefinitionSelect, - columnParts: string[], - parentContext: TranspilerContext, - forEachContextMap: Map, - ): void { - if (!select.select) { - return; - } - - for (const nestedSelect of select.select) { - if (this.isForEachSelect(nestedSelect)) { - const nestedContext = forEachContextMap.get(nestedSelect); - if (nestedContext && nestedSelect.column) { - this.addColumnsToList( - nestedSelect.column, - columnParts, - nestedContext, - ); - } - } else if (nestedSelect.column) { - this.addColumnsToList(nestedSelect.column, columnParts, parentContext); - } - } - } - - /** - * Add nested select columns for non-forEach select. - */ - private addNestedSelectColumnsForNonForEach( - select: ViewDefinitionSelect, - columnParts: string[], - context: TranspilerContext, - forEachContextMap: Map, - ): void { - if (!select.select) { - return; - } - - for (const nestedSelect of select.select) { - if (this.isForEachSelect(nestedSelect)) { - const forEachContext = forEachContextMap.get(nestedSelect); - if (forEachContext && nestedSelect.column) { - this.addColumnsToList( - nestedSelect.column, - columnParts, - forEachContext, - ); - } - } else if (nestedSelect.column) { - this.addColumnsToList(nestedSelect.column, columnParts, context); - } - } - } - - /** - * Add unionAll columns for a select. - */ - private addUnionAllColumnsForSelect( - select: ViewDefinitionSelect, - unionChoice: number, - columnParts: string[], - defaultContext: TranspilerContext, - forEachContextMap: Map, - ): void { - if (unionChoice < 0 || !select.unionAll?.[unionChoice]) { - return; - } - - const chosenBranch = select.unionAll[unionChoice]; - if (!chosenBranch.column) { - return; - } - - const branchContext = this.isForEachSelect(chosenBranch) - ? forEachContextMap.get(chosenBranch) - : defaultContext; - - if (branchContext) { - this.addColumnsToList(chosenBranch.column, columnParts, branchContext); - } - } - - /** - * Add columns to the column parts list. - */ - private addColumnsToList( - columns: ViewDefinitionColumn[], - columnParts: string[], - context: TranspilerContext, - ): void { - for (const column of columns) { - const columnSql = this.columnGenerator.generateExpression( - column, - context, - ); - columnParts.push(`${columnSql} AS [${column.name}]`); - } - } - - /** - * Check if a select is a forEach or forEachOrNull. - */ - private isForEachSelect(select: ViewDefinitionSelect): boolean { - return !!(select.forEach ?? select.forEachOrNull); - } -} diff --git a/src/queryGenerator/SelectCombinationExpander.ts b/src/queryGenerator/SelectCombinationExpander.ts deleted file mode 100644 index 0f56660..0000000 --- a/src/queryGenerator/SelectCombinationExpander.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Expands unionAll combinations from ViewDefinition select elements. - */ - -import { ViewDefinitionSelect } from "../types.js"; - -/** - * Represents a specific combination of selects with their unionAll choices. - */ -export interface SelectCombination { - selects: ViewDefinitionSelect[]; - unionChoices: number[]; // -1 means no union choice, >= 0 means index in unionAll array. -} - -/** - * Handles expansion of all possible unionAll combinations. - */ -export class SelectCombinationExpander { - /** - * Expand all possible unionAll combinations from select elements. - */ - expandCombinations(selects: ViewDefinitionSelect[]): SelectCombination[] { - let combinations: SelectCombination[] = [{ selects: [], unionChoices: [] }]; - - for (const select of selects) { - combinations = this.expandSelectCombinations(select, combinations); - } - - return combinations; - } - - /** - * Expand combinations for a single select element. - * Handles nested unionAll by recursively expanding them. - */ - private expandSelectCombinations( - select: ViewDefinitionSelect, - currentCombinations: SelectCombination[], - ): SelectCombination[] { - const newCombinations: SelectCombination[] = []; - - for (const combination of currentCombinations) { - if (select.unionAll && select.unionAll.length > 0) { - this.expandUnionAllOptions(select, combination, newCombinations); - } else { - this.addNonUnionCombination(select, combination, newCombinations); - } - } - - return newCombinations; - } - - /** - * Expand unionAll options for a select. - */ - private expandUnionAllOptions( - select: ViewDefinitionSelect, - combination: SelectCombination, - newCombinations: SelectCombination[], - ): void { - const unionAll = select.unionAll; - if (!unionAll) return; - - for (let i = 0; i < unionAll.length; i++) { - const unionOption = unionAll[i]; - - if (unionOption.unionAll && unionOption.unionAll.length > 0) { - this.expandNestedUnion( - select, - i, - unionOption, - combination, - newCombinations, - ); - } else { - this.addSimpleUnionCombination(select, i, combination, newCombinations); - } - } - } - - /** - * Expand nested unionAll within a unionAll option. - */ - private expandNestedUnion( - select: ViewDefinitionSelect, - unionIndex: number, - unionOption: ViewDefinitionSelect, - combination: SelectCombination, - newCombinations: SelectCombination[], - ): void { - const nestedCombinations = this.expandSelectCombinations(unionOption, [ - { selects: [], unionChoices: [] }, - ]); - - for (const nestedComb of nestedCombinations) { - const newCombination: SelectCombination = { - selects: [...combination.selects, select, ...nestedComb.selects], - unionChoices: [ - ...combination.unionChoices, - unionIndex, - ...nestedComb.unionChoices, - ], - }; - newCombinations.push(newCombination); - } - } - - /** - * Add a simple unionAll combination (no nested unionAll). - */ - private addSimpleUnionCombination( - select: ViewDefinitionSelect, - unionIndex: number, - combination: SelectCombination, - newCombinations: SelectCombination[], - ): void { - const newCombination: SelectCombination = { - selects: [...combination.selects, select], - unionChoices: [...combination.unionChoices, unionIndex], - }; - newCombinations.push(newCombination); - } - - /** - * Add a combination for a select without unionAll. - */ - private addNonUnionCombination( - select: ViewDefinitionSelect, - combination: SelectCombination, - newCombinations: SelectCombination[], - ): void { - const newCombination: SelectCombination = { - selects: [...combination.selects, select], - unionChoices: [...combination.unionChoices, -1], - }; - newCombinations.push(newCombination); - } -} diff --git a/src/queryGenerator/index.ts b/src/queryGenerator/index.ts index 625ce2b..1d89fed 100644 --- a/src/queryGenerator/index.ts +++ b/src/queryGenerator/index.ts @@ -3,12 +3,5 @@ */ export { PathParser } from "./PathParser.js"; -export { - SelectCombination, - SelectCombinationExpander, -} from "./SelectCombinationExpander.js"; -export { ForEachProcessor } from "./ForEachProcessor.js"; -export { RepeatProcessor, RepeatContext } from "./RepeatProcessor.js"; -export { SelectClauseBuilder } from "./SelectClauseBuilder.js"; export { WhereClauseBuilder } from "./WhereClauseBuilder.js"; export { ColumnExpressionGenerator } from "./ColumnExpressionGenerator.js"; diff --git a/src/queryGenerator/treeWalker/aliasGenerator.ts b/src/queryGenerator/treeWalker/aliasGenerator.ts new file mode 100644 index 0000000..916b7ad --- /dev/null +++ b/src/queryGenerator/treeWalker/aliasGenerator.ts @@ -0,0 +1,14 @@ +/** + * Sole producer of all SQL aliases generated by the tree walker. + * + * Routing every alias through here guarantees uniqueness across the whole + * compile (the counter lives on Context.cteCounter and is shared by every + * recursive walk call). + */ + +import type { Context } from "./types.js"; + +export function freshAlias(ctx: Context, prefix: string): string { + const id = ctx.cteCounter.value++; + return `${prefix}_${id}`; +} diff --git a/src/queryGenerator/treeWalker/classify.ts b/src/queryGenerator/treeWalker/classify.ts new file mode 100644 index 0000000..d7b0468 --- /dev/null +++ b/src/queryGenerator/treeWalker/classify.ts @@ -0,0 +1,19 @@ +/** + * Classifies a ViewDefinition select node into one of the walker's NodeKinds. + */ + +import type { ViewDefinitionSelect } from "../../types.js"; +import type { NodeKind } from "./types.js"; + +export function classifyNode(node: ViewDefinitionSelect): NodeKind { + // Operators are ordered outer-to-inner. A node with multiple operators + // (e.g. `{forEach, unionAll}`) is processed as the outer one; the inner + // operator surfaces when the walker descends into the node's + // `{column, select, unionAll}` sub-node. + if (node.forEachOrNull !== undefined) return "ForEachOrNull"; + if (node.forEach !== undefined) return "ForEach"; + if (node.repeat && node.repeat.length > 0) return "Repeat"; + if (node.unionAll && node.unionAll.length > 0) return "UnionAll"; + if (node.select && node.select.length > 0) return "Group"; + return "ColumnsOnly"; +} diff --git a/src/queryGenerator/treeWalker/compile.ts b/src/queryGenerator/treeWalker/compile.ts new file mode 100644 index 0000000..2ecd523 --- /dev/null +++ b/src/queryGenerator/treeWalker/compile.ts @@ -0,0 +1,109 @@ +/** + * Public entry point for the tree-walker query generator. + * + * Wraps viewDef.select as a synthetic Group node, walks it, and renders + * the resulting Fragment as a single T-SQL statement. + */ + +import { Transpiler, TranspilerContext } from "../../fhirpath/transpiler.js"; +import type { + ColumnInfo, + TranspilationResult, + ViewDefinition, + ViewDefinitionSelect, +} from "../../types.js"; +import { ColumnExpressionGenerator } from "../ColumnExpressionGenerator.js"; +import { PathParser } from "../PathParser.js"; +import { WhereClauseBuilder } from "../WhereClauseBuilder.js"; +import { renderRoot } from "./render.js"; +import type { Context, PartitionKey } from "./types.js"; +import { makeWalker } from "./walker.js"; + +export interface CompileOptions { + tableName: string; + schemaName: string; + testId?: string; + transpilerCtx: TranspilerContext; +} + +export function compileViewDefinition( + viewDef: ViewDefinition, + options: CompileOptions, +): TranspilationResult { + const resourceAlias = options.transpilerCtx.resourceAlias; + const ctx = buildRootContext(resourceAlias, options.transpilerCtx); + + const columnGenerator = new ColumnExpressionGenerator(); + const whereClauseBuilder = new WhereClauseBuilder(); + const pathParser = new PathParser(); + + const rootNode: ViewDefinitionSelect = { select: viewDef.select }; + const walk = makeWalker({ + columnGenerator, + pathParser, + schemaName: options.schemaName, + tableName: options.tableName, + }); + const fragment = walk(rootNode, ctx); + + const sql = renderRoot(fragment, viewDef, { + resourceAlias, + schemaName: options.schemaName, + tableName: options.tableName, + testId: options.testId, + whereClauseBuilder, + transpilerCtx: options.transpilerCtx, + }); + + return { sql, columns: collectColumnMetadata(viewDef.select) }; +} + +function buildRootContext( + resourceAlias: string, + transpilerCtx: TranspilerContext, +): Context { + const idKey: PartitionKey = { + name: "id", + sqlExpr: `[${resourceAlias}].[id]`, + sqlType: "INT", + }; + return { + resourceAlias, + // Use unbracketed `r.json` for the JSON source: matches the FHIRPath + // transpiler's expectations (see visitor.ts handleJsonQueryMember which + // pattern-matches on the source string). + source: `${resourceAlias}.json`, + partitionKeys: [idKey], + scalarColumns: [], + ancestorApplies: "", + nullable: false, + cteCounter: { value: 0 }, + transpilerCtx, + }; +} + +/** + * Walk the select tree and collect ColumnInfo metadata in lexical order. + * Mirrors the behaviour of QueryGenerator.collectAllColumns so the public + * TranspilationResult.columns shape is unchanged. + */ +function collectColumnMetadata( + selects: ViewDefinitionSelect[], +): ColumnInfo[] { + const out: ColumnInfo[] = []; + for (const select of selects) { + if (select.column) { + for (const column of select.column) { + out.push({ + name: column.name, + type: Transpiler.inferSqlType(column.type, column.tag), + nullable: true, + description: column.description, + }); + } + } + if (select.select) out.push(...collectColumnMetadata(select.select)); + if (select.unionAll) out.push(...collectColumnMetadata(select.unionAll)); + } + return out; +} diff --git a/src/queryGenerator/treeWalker/cteTemplates.ts b/src/queryGenerator/treeWalker/cteTemplates.ts new file mode 100644 index 0000000..b6ad150 --- /dev/null +++ b/src/queryGenerator/treeWalker/cteTemplates.ts @@ -0,0 +1,135 @@ +/** + * SQL string templates for the recursive CTE produced by `repeat`. + * + * Builds the anchor + recursive members, propagating partition keys and + * baked scalar columns through both. Multi-segment paths chain CROSS APPLY + * OPENJSON per segment. + * + * Note: `__path` is built by string-concatenating each child's `[key]`. + * If a JSON object key contains a literal `.`, two distinct nodes could + * theoretically produce equal `__path` strings. FHIR JSON keys do not + * contain dots in practice; not a blocker. + */ + +import type { + CteDefinition, + PartitionKey, + ScalarColumn, +} from "./types.js"; + +interface OpenJsonChain { + applyClauses: string; + lastAlias: string; +} + +export interface BuildRepeatCteArgs { + cteAlias: string; + /** FHIRPath strings — first is the anchor path; all are recursive paths. */ + paths: string[]; + /** JSON source expression for the anchor (e.g. "r.json", "forEach_0.value"). */ + source: string; + /** "FROM AS [r]" — the resource table reference for the anchor. */ + fromClause: string; + /** CROSS/OUTER APPLY chain inherited from ancestors (each starts with "\n"). */ + ancestorApplies: string; + /** Columns lexically captured above the repeat; baked into the anchor. */ + scalarColumns: ScalarColumn[]; + /** Partition keys propagated through anchor and recursive members. */ + partitionKeys: PartitionKey[]; + /** Resource-level WHERE applied to the anchor (or null to omit). */ + resourcePredicate: string | null; +} + +export function buildRepeatCte(args: BuildRepeatCteArgs): CteDefinition { + const anchor = buildAnchorMember(args); + const recBlocks = args.paths.map((p, i) => buildRecursiveMember(args, p, i)); + const body = `${anchor} + UNION ALL +${recBlocks.join("\n UNION ALL\n")}`; + return { alias: args.cteAlias, body }; +} + +function buildAnchorMember(args: BuildRepeatCteArgs): string { + const { + paths, + source, + fromClause, + ancestorApplies, + scalarColumns, + partitionKeys, + resourcePredicate, + } = args; + const keyProj = partitionKeys + .map((k) => `${k.sqlExpr} AS [${k.name}]`) + .join(",\n "); + const scalarProj = scalarColumns + .map((s) => `${s.sqlExpr} AS ${s.name}`) + .join(",\n "); + const projLines = [keyProj, scalarProj] + .filter((s) => s.length > 0) + .join(",\n "); + const chain = buildOpenJsonChain(source, paths[0], "anchor"); + const wherePart = resourcePredicate ? `\n ${resourcePredicate}` : ""; + + return ` SELECT + ${projLines}, + CAST(${chain.lastAlias}.[key] AS NVARCHAR(MAX)) AS __path, + ${chain.lastAlias}.value AS item_json, + 0 AS depth + ${fromClause}${ancestorApplies} + ${chain.applyClauses}${wherePart}`; +} + +function buildRecursiveMember( + args: BuildRepeatCteArgs, + path: string, + index: number, +): string { + const { cteAlias, scalarColumns, partitionKeys } = args; + const propagatedKeyRefs = partitionKeys + .map((k) => `cte.[${k.name}]`) + .join(", "); + const propagatedScalarRefs = scalarColumns + .map((s) => `cte.${s.name}`) + .join(", "); + const head = [propagatedKeyRefs, propagatedScalarRefs] + .filter((s) => s.length > 0) + .join(", "); + const chain = buildOpenJsonChain("cte.item_json", path, `child_${index}`); + return ` SELECT + ${head}, + cte.__path + '.' + CAST(${chain.lastAlias}.[key] AS NVARCHAR(4000)) AS __path, + ${chain.lastAlias}.value AS item_json, + cte.depth + 1 + FROM ${cteAlias} AS cte + ${chain.applyClauses}`; +} + +/** + * Builds the CROSS APPLY OPENJSON chain for a (possibly multi-segment) path. + * For "a.b.c" produces three chained APPLYs; the last alias is `finalAlias`. + */ +function buildOpenJsonChain( + source: string, + path: string, + finalAlias: string, +): OpenJsonChain { + const segments = path.split("."); + if (segments.length === 1) { + return { + applyClauses: `CROSS APPLY OPENJSON(${source}, '$.${segments[0]}') AS ${finalAlias}`, + lastAlias: finalAlias, + }; + } + + let chain = ""; + let currentSource = source; + for (let i = 0; i < segments.length; i++) { + const isLast = i === segments.length - 1; + const alias = isLast ? finalAlias : `${finalAlias}_${i}`; + if (i > 0) chain += "\n "; + chain += `CROSS APPLY OPENJSON(${currentSource}, '$.${segments[i]}') AS ${alias}`; + currentSource = `${alias}.value`; + } + return { applyClauses: chain, lastAlias: finalAlias }; +} diff --git a/src/queryGenerator/treeWalker/index.ts b/src/queryGenerator/treeWalker/index.ts new file mode 100644 index 0000000..6290e6f --- /dev/null +++ b/src/queryGenerator/treeWalker/index.ts @@ -0,0 +1,21 @@ +/** + * Tree-walker query generator (greenfield, gated by USE_TREE_WALKER). + */ + +export { compileViewDefinition } from "./compile.js"; +export type { CompileOptions } from "./compile.js"; +export { classifyNode } from "./classify.js"; +export { freshAlias } from "./aliasGenerator.js"; +export { makeWalker } from "./walker.js"; +export { mergeSiblings } from "./mergeSiblings.js"; +export { renderRoot } from "./render.js"; +export type { + Context, + Fragment, + PartitionKey, + ScalarColumn, + ProjectedColumn, + CteDefinition, + NodeKind, + RowOrigin, +} from "./types.js"; diff --git a/src/queryGenerator/treeWalker/mergeSiblings.ts b/src/queryGenerator/treeWalker/mergeSiblings.ts new file mode 100644 index 0000000..741231e --- /dev/null +++ b/src/queryGenerator/treeWalker/mergeSiblings.ts @@ -0,0 +1,47 @@ +/** + * Merges sibling fragments produced by walking children of a Group. + * + * Row-shaped siblings compose horizontally — APPLY chains and columns + * concatenate. Set-shaped siblings (Repeat CTEs) contribute INNER JOIN + * clauses to their CTEs; the outer FROM remains the resource table so row + * siblings can reference its scope. + * + * For multiple set siblings (Step 5 onward), the partition-key composite + * join logic ensures rows align correctly within the enclosing scope. + */ + +import type { Context, Fragment } from "./types.js"; + +export function mergeSiblings(fragments: Fragment[], ctx: Context): Fragment { + if (fragments.length === 0) { + return { + ctes: [], + fromClause: "", + fromExtensions: "", + columns: [], + partitionKeys: ctx.partitionKeys, + rowOrigin: "row", + }; + } + + if (fragments.length === 1) return fragments[0]; + + const ctes = fragments.flatMap((f) => f.ctes); + const fromExtensions = fragments.map((f) => f.fromExtensions).join(""); + const columns = fragments.flatMap((f) => f.columns); + const nullableHere = fragments.some((f) => f.nullableHere); + + // Row siblings + zero or more set siblings: each set fragment already carries + // its own INNER JOIN to its CTE, joined on the partition keys it inherited + // from `ctx`. The outer FROM stays as the resource table so row siblings can + // keep their references to `r` valid. + return { + ctes, + fromClause: "", + fromExtensions, + columns, + partitionKeys: ctx.partitionKeys, + rowOrigin: "row", + nullableHere, + }; +} diff --git a/src/queryGenerator/treeWalker/operators/columnsOnly.ts b/src/queryGenerator/treeWalker/operators/columnsOnly.ts new file mode 100644 index 0000000..8cbb4fe --- /dev/null +++ b/src/queryGenerator/treeWalker/operators/columnsOnly.ts @@ -0,0 +1,40 @@ +/** + * Walker for ColumnsOnly nodes — emits the projected columns for the + * select.column[] array using the current transpiler context. + */ + +import type { + ViewDefinitionColumn, + ViewDefinitionSelect, +} from "../../../types.js"; +import type { ColumnExpressionGenerator } from "../../ColumnExpressionGenerator.js"; +import type { Context, Fragment, ProjectedColumn } from "../types.js"; + +export function projectColumns( + columns: ViewDefinitionColumn[], + ctx: Context, + columnGenerator: ColumnExpressionGenerator, +): ProjectedColumn[] { + return columns.map((column) => ({ + name: column.name, + sqlExpr: columnGenerator.generateExpression(column, ctx.transpilerCtx), + })); +} + +export function walkColumnsOnly( + node: ViewDefinitionSelect, + ctx: Context, + columnGenerator: ColumnExpressionGenerator, +): Fragment { + const columns = node.column + ? projectColumns(node.column, ctx, columnGenerator) + : []; + return { + ctes: [], + fromClause: "", + fromExtensions: "", + columns, + partitionKeys: ctx.partitionKeys, + rowOrigin: "row", + }; +} diff --git a/src/queryGenerator/treeWalker/operators/forEach.ts b/src/queryGenerator/treeWalker/operators/forEach.ts new file mode 100644 index 0000000..6d49a22 --- /dev/null +++ b/src/queryGenerator/treeWalker/operators/forEach.ts @@ -0,0 +1,200 @@ +/** + * Walker for ForEach / ForEachOrNull nodes. + * + * Emits a CROSS APPLY (or OUTER APPLY) clause that iterates over a JSON + * array. Threads `ctx.source` and `ctx.transpilerCtx` so child nodes + * project columns relative to the iteration value, and appends a + * `_key` partition key. + * + * Path-handling parity is delegated to PathParser (`.where()`, `.first()`, + * array indexing, multi-segment array flattening). + */ + +import type { TranspilerContext } from "../../../fhirpath/transpiler.js"; +import type { ViewDefinitionSelect } from "../../../types.js"; +import type { PathParser } from "../../PathParser.js"; +import { freshAlias } from "../aliasGenerator.js"; +import type { Context, Fragment, PartitionKey } from "../types.js"; + +interface ForEachDeps { + pathParser: PathParser; +} + +export function walkForEach( + node: ViewDefinitionSelect, + ctx: Context, + walk: (n: ViewDefinitionSelect, c: Context) => Fragment, + deps: ForEachDeps, +): Fragment { + const isOrNull = node.forEachOrNull !== undefined; + const rawPath = (node.forEach ?? node.forEachOrNull) ?? ""; + const applyType = isOrNull ? "OUTER APPLY" : "CROSS APPLY"; + const alias = freshAlias(ctx, "forEach"); + + const applyClause = buildForEachApply( + rawPath, + ctx.source, + alias, + applyType, + ctx.transpilerCtx, + deps.pathParser, + ); + + const innerCtx = buildInnerCtx(ctx, alias, rawPath, applyClause, isOrNull); + const innerNode: ViewDefinitionSelect = { + column: node.column, + select: node.select, + unionAll: node.unionAll, + }; + const inner = walk(innerNode, innerCtx); + + return { + ...inner, + fromExtensions: applyClause + inner.fromExtensions, + nullableHere: isOrNull || inner.nullableHere, + }; +} + +function buildInnerCtx( + ctx: Context, + alias: string, + rawPath: string, + applyClause: string, + isOrNull: boolean, +): Context { + const innerTranspilerCtx: TranspilerContext = { + ...ctx.transpilerCtx, + iterationContext: `${alias}.value`, + currentForEachAlias: alias, + forEachSource: ctx.source, + forEachPath: `$.${rawPath}`, + }; + const innerKey: PartitionKey = { + name: `${alias}_key`, + sqlExpr: `${alias}.[key]`, + sqlType: "NVARCHAR(4000)", + }; + return { + ...ctx, + source: `${alias}.value`, + partitionKeys: [...ctx.partitionKeys, innerKey], + ancestorApplies: ctx.ancestorApplies + applyClause, + nullable: ctx.nullable || isOrNull, + transpilerCtx: innerTranspilerCtx, + }; +} + +/** + * Builds the CROSS/OUTER APPLY clause string for a forEach path, handling + * `.where()`, `.first()`, array indexing, and multi-segment array + * flattening. + */ +function buildForEachApply( + rawPath: string, + source: string, + alias: string, + applyType: string, + transpilerCtx: TranspilerContext, + pathParser: PathParser, +): string { + const { + path: pathWithoutWhere, + whereCondition, + useFirst, + } = pathParser.parseFhirPathWhere(rawPath, transpilerCtx); + const { path: forEachPath, arrayIndex } = + pathParser.parseArrayIndexing(pathWithoutWhere); + const arrayPaths = pathParser.detectArrayFlatteningPaths(forEachPath); + + if (arrayPaths.length > 1) { + return buildNestedApply( + arrayPaths, + source, + alias, + applyType, + pathParser, + arrayIndex, + whereCondition, + ); + } + + return buildSimpleApply( + applyType, + source, + forEachPath, + alias, + arrayIndex, + whereCondition, + useFirst, + ); +} + +function buildSimpleApply( + applyType: string, + source: string, + path: string, + alias: string, + arrayIndex: number | null, + whereCondition: string | null, + useFirst: boolean, +): string { + const whereClauses: string[] = []; + if (arrayIndex !== null) whereClauses.push(`[key] = '${arrayIndex}'`); + if (whereCondition !== null) whereClauses.push(whereCondition); + + if (whereClauses.length > 0 || useFirst) { + const topClause = useFirst ? "TOP 1 " : ""; + const orderBy = useFirst ? " ORDER BY [key]" : ""; + const whereClause = + whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : ""; + return `\n${applyType} ( + SELECT ${topClause}* FROM OPENJSON(${source}, '$.${path}') + ${whereClause}${orderBy} + ) AS ${alias}`; + } + + return `\n${applyType} OPENJSON(${source}, '$.${path}') AS ${alias}`; +} + +function buildNestedApply( + arrayPaths: string[], + source: string, + finalAlias: string, + applyType: string, + pathParser: PathParser, + arrayIndex: number | null, + whereCondition: string | null, +): string { + let clauses = ""; + let currentSource = source; + + for (let i = 0; i < arrayPaths.length; i++) { + const isLast = i === arrayPaths.length - 1; + const alias = isLast ? finalAlias : `${finalAlias}_nest${i}`; + const segment = pathParser.extractPathSegment(arrayPaths, i); + const { cleanSegment, segmentIndex } = + pathParser.parseSegmentIndexing(segment); + const jsonPath = `$.${cleanSegment}`; + + const whereClauses: string[] = []; + if (segmentIndex !== null) { + whereClauses.push(`[key] = '${segmentIndex}'`); + } else if (isLast && arrayIndex !== null) { + whereClauses.push(`[key] = '${arrayIndex}'`); + } + if (isLast && whereCondition !== null) whereClauses.push(whereCondition); + + if (whereClauses.length > 0) { + clauses += `\n${applyType} ( + SELECT * FROM OPENJSON(${currentSource}, '${jsonPath}') + WHERE ${whereClauses.join(" AND ")} + ) AS ${alias}`; + } else { + clauses += `\n${applyType} OPENJSON(${currentSource}, '${jsonPath}') AS ${alias}`; + } + + currentSource = `${alias}.value`; + } + + return clauses; +} diff --git a/src/queryGenerator/treeWalker/operators/group.ts b/src/queryGenerator/treeWalker/operators/group.ts new file mode 100644 index 0000000..ad79e4f --- /dev/null +++ b/src/queryGenerator/treeWalker/operators/group.ts @@ -0,0 +1,48 @@ +/** + * Walker for Group nodes — visits each child and merges their fragments. + * + * If the node has both `column` and `select`, the columns are emitted as + * an implicit first ColumnsOnly child so they appear before the children's + * projections in lexical order. + */ + +import type { ViewDefinitionSelect } from "../../../types.js"; +import type { ColumnExpressionGenerator } from "../../ColumnExpressionGenerator.js"; +import { mergeSiblings } from "../mergeSiblings.js"; +import type { Context, Fragment } from "../types.js"; +import { projectColumns, walkColumnsOnly } from "./columnsOnly.js"; + +export function walkGroup( + node: ViewDefinitionSelect, + ctx: Context, + walk: (n: ViewDefinitionSelect, c: Context) => Fragment, + columnGenerator: ColumnExpressionGenerator, +): Fragment { + const children: Fragment[] = []; + + if (node.column && node.column.length > 0) { + children.push(walkColumnsOnly(node, ctx, columnGenerator)); + } + + if (node.select) { + for (const child of node.select) { + children.push(walk(child, ctx)); + } + } + + if (children.length === 0) { + return { + ctes: [], + fromClause: "", + fromExtensions: "", + columns: [], + partitionKeys: ctx.partitionKeys, + rowOrigin: "row", + }; + } + + return mergeSiblings(children, ctx); +} + +// Re-export so other operators can call into ColumnsOnly through Group. +export { projectColumns }; diff --git a/src/queryGenerator/treeWalker/operators/repeat.ts b/src/queryGenerator/treeWalker/operators/repeat.ts new file mode 100644 index 0000000..e827d5e --- /dev/null +++ b/src/queryGenerator/treeWalker/operators/repeat.ts @@ -0,0 +1,107 @@ +/** + * Walker for Repeat nodes — emits a recursive CTE and returns a set Fragment. + * + * The CTE projects the current partition keys and any baked scalar columns + * plus a content-derived `__path` for stable identity across re-evaluations. + * The Fragment's `joins` is an INNER JOIN to the CTE on the partition key + * `[id]`; sibling-level composite-key joins are added by `mergeSiblings`. + */ + +import type { TranspilerContext } from "../../../fhirpath/transpiler.js"; +import type { ViewDefinitionSelect } from "../../../types.js"; +import { freshAlias } from "../aliasGenerator.js"; +import { buildRepeatCte } from "../cteTemplates.js"; +import type { Context, Fragment, PartitionKey } from "../types.js"; + +export interface RepeatDeps { + schemaName: string; + tableName: string; +} + +export function walkRepeat( + node: ViewDefinitionSelect, + ctx: Context, + walk: (n: ViewDefinitionSelect, c: Context) => Fragment, + deps: RepeatDeps, +): Fragment { + const cteAlias = freshAlias(ctx, "repeat"); + const paths = node.repeat ?? []; + if (paths.length === 0) { + throw new Error("walkRepeat: repeat node has empty paths array"); + } + + const tableRef = `[${deps.schemaName}].[${deps.tableName}]`; + const cte = buildRepeatCte({ + cteAlias, + paths, + source: ctx.source, + fromClause: `FROM ${tableRef} AS [${ctx.resourceAlias}]`, + ancestorApplies: ctx.ancestorApplies, + scalarColumns: ctx.scalarColumns, + partitionKeys: ctx.partitionKeys, + resourcePredicate: null, // Resource-level WHERE goes in the outer SELECT. + }); + + const joinClause = buildJoinClause(cteAlias, ctx); + const innerCtx = buildRepeatInnerCtx(ctx, cteAlias, paths, joinClause); + const innerNode: ViewDefinitionSelect = { + column: node.column, + select: node.select, + unionAll: node.unionAll, + }; + const inner = walk(innerNode, innerCtx); + + return { + ctes: [cte, ...inner.ctes], + fromClause: "", + // Join the CTE FIRST so subsequent applies/joins inside the repeat + // (which reference `.item_json`) have the alias in scope. + fromExtensions: joinClause + inner.fromExtensions, + columns: inner.columns, + partitionKeys: innerCtx.partitionKeys, + rowOrigin: "set", + fromAlias: cteAlias, + nullableHere: inner.nullableHere, + }; +} + +/** + * Outer-SELECT join condition aligning this CTE's rows with the enclosing + * partition (resource id plus any forEach/repeat keys above this scope). + */ +function buildJoinClause(cteAlias: string, ctx: Context): string { + const joinConditions = ctx.partitionKeys + .map((k) => `${cteAlias}.[${k.name}] = ${k.sqlExpr}`) + .join(" AND "); + return `\nINNER JOIN ${cteAlias} ON ${joinConditions}`; +} + +function buildRepeatInnerCtx( + ctx: Context, + cteAlias: string, + paths: string[], + joinClause: string, +): Context { + const newKey: PartitionKey = { + name: `${cteAlias}_path`, + sqlExpr: `${cteAlias}.__path`, + sqlType: "NVARCHAR(MAX)", + }; + const innerTranspilerCtx: TranspilerContext = { + ...ctx.transpilerCtx, + iterationContext: `${cteAlias}.item_json`, + currentForEachAlias: cteAlias, + forEachSource: ctx.source, + forEachPath: paths.map((p) => `$.${p}`).join(", "), + }; + // Propagate the join into ancestorApplies so any *nested* Repeat builds + // its CTE anchor with this CTE in scope. + return { + ...ctx, + source: `${cteAlias}.item_json`, + partitionKeys: [...ctx.partitionKeys, newKey], + scalarColumns: [], // Already baked into the CTE. + ancestorApplies: ctx.ancestorApplies + joinClause, + transpilerCtx: innerTranspilerCtx, + }; +} diff --git a/src/queryGenerator/treeWalker/operators/unionAll.ts b/src/queryGenerator/treeWalker/operators/unionAll.ts new file mode 100644 index 0000000..14515e9 --- /dev/null +++ b/src/queryGenerator/treeWalker/operators/unionAll.ts @@ -0,0 +1,128 @@ +/** + * Walker for UnionAll nodes. + * + * Emits a local derived table with one SELECT per branch, joined by + * `UNION ALL`, wrapped as `CROSS APPLY (...) AS ua_`. CTEs from any + * branch (e.g. enclosed Repeats) bubble up to the top-level WITH clause. + * + * Branches must produce the same column names and types (SoF spec + * requirement). The walker aligns them positionally — branch[0]'s names + * become the outer projection. + * + * If the same node also has `column[]` or `select[]` alongside `unionAll`, + * those are emitted as outer siblings (they live in the SELECT list at the + * same level as the ua_ projection, evaluated in the parent scope). + */ + +import type { ViewDefinitionSelect } from "../../../types.js"; +import type { ColumnExpressionGenerator } from "../../ColumnExpressionGenerator.js"; +import { freshAlias } from "../aliasGenerator.js"; +import { mergeSiblings } from "../mergeSiblings.js"; +import type { + Context, + CteDefinition, + Fragment, + ProjectedColumn, +} from "../types.js"; +import { projectColumns } from "./columnsOnly.js"; + +export interface UnionAllDeps { + columnGenerator: ColumnExpressionGenerator; +} + +export function walkUnionAll( + node: ViewDefinitionSelect, + ctx: Context, + walk: (n: ViewDefinitionSelect, c: Context) => Fragment, + deps: UnionAllDeps, +): Fragment { + const branches = node.unionAll ?? []; + if (branches.length === 0) throw new Error("walkUnionAll: empty unionAll"); + + const branchFragments: Fragment[] = branches.map((b) => walk(b, ctx)); + const uaFragment = buildUnionAllFragment(branchFragments, ctx); + const outerSiblings = collectOuterSiblings(node, ctx, walk, deps); + + return outerSiblings.length === 0 + ? uaFragment + : mergeSiblings([...outerSiblings, uaFragment], ctx); +} + +function buildUnionAllFragment( + branchFragments: Fragment[], + ctx: Context, +): Fragment { + const uaAlias = freshAlias(ctx, "ua"); + const allCtes: CteDefinition[] = branchFragments.flatMap((f) => f.ctes); + const referenceColumns = branchFragments[0].columns; + const branchSqls = branchFragments.map((f) => + renderBranchSelect(f, referenceColumns), + ); + const unionDerivedTable = `(\n ${branchSqls.join( + "\n UNION ALL\n ", + )}\n) AS ${uaAlias}`; + const uaColumns: ProjectedColumn[] = referenceColumns.map((c) => ({ + name: c.name, + sqlExpr: `${uaAlias}.[${c.name}]`, + })); + return { + ctes: allCtes, + fromClause: "", + fromExtensions: `\nCROSS APPLY ${unionDerivedTable}`, + columns: uaColumns, + partitionKeys: ctx.partitionKeys, + rowOrigin: "row", + nullableHere: branchFragments.some((f) => f.nullableHere), + }; +} + +function collectOuterSiblings( + node: ViewDefinitionSelect, + ctx: Context, + walk: (n: ViewDefinitionSelect, c: Context) => Fragment, + deps: UnionAllDeps, +): Fragment[] { + const out: Fragment[] = []; + if (node.column && node.column.length > 0) { + out.push({ + ctes: [], + fromClause: "", + fromExtensions: "", + columns: projectColumns(node.column, ctx, deps.columnGenerator), + partitionKeys: ctx.partitionKeys, + rowOrigin: "row", + }); + } + if (node.select) { + for (const child of node.select) out.push(walk(child, ctx)); + } + return out; +} + +/** + * Renders one branch's SELECT inside the unionAll derived table. + * + * The seed `(SELECT 1 AS _) AS _seed_X` ensures the branch always has a + * FROM clause to attach its CROSS/OUTER APPLY chain to, and keeps the SQL + * uniform whether the branch has operators or not. + * + * Columns are re-aliased to branch[0]'s names so the outer projection by + * name works for every branch row. + */ +function renderBranchSelect( + fragment: Fragment, + referenceColumns: ProjectedColumn[], +): string { + const projection = referenceColumns + .map((ref, i) => { + const branchCol = fragment.columns[i]; + if (!branchCol) { + throw new Error( + `unionAll branch is missing column at index ${i} (expected '${ref.name}')`, + ); + } + return `${branchCol.sqlExpr} AS [${ref.name}]`; + }) + .join(", "); + return `SELECT ${projection} FROM (SELECT 1 AS _) AS _seed${fragment.fromExtensions}`; +} diff --git a/src/queryGenerator/treeWalker/render.ts b/src/queryGenerator/treeWalker/render.ts new file mode 100644 index 0000000..29ecf28 --- /dev/null +++ b/src/queryGenerator/treeWalker/render.ts @@ -0,0 +1,57 @@ +/** + * Renders a root Fragment into the final T-SQL statement: + * + * WITH SELECT FROM WHERE + */ + +import type { TranspilerContext } from "../../fhirpath/transpiler.js"; +import type { ViewDefinition } from "../../types.js"; +import type { WhereClauseBuilder } from "../WhereClauseBuilder.js"; +import type { Fragment } from "./types.js"; + +export interface RenderOptions { + resourceAlias: string; + schemaName: string; + tableName: string; + testId?: string; + whereClauseBuilder: WhereClauseBuilder; + transpilerCtx: TranspilerContext; +} + +export function renderRoot( + fragment: Fragment, + viewDef: ViewDefinition, + options: RenderOptions, +): string { + const { resourceAlias, schemaName, tableName } = options; + const tableRef = `[${schemaName}].[${tableName}]`; + + const cteSection = + fragment.ctes.length > 0 + ? `WITH\n${fragment.ctes.map((c) => `${c.alias} AS (\n${c.body}\n)`).join(",\n")}\n` + : ""; + + const selectList = fragment.columns + .map((c) => `${c.sqlExpr} AS [${c.name}]`) + .join(",\n "); + + const fromClause = + fragment.fromClause !== "" + ? fragment.fromClause + : `FROM ${tableRef} AS [${resourceAlias}]`; + + const whereClause = options.whereClauseBuilder.buildWhereClause( + viewDef.resource, + resourceAlias, + options.testId, + viewDef.where, + options.transpilerCtx, + ); + + let body = `SELECT\n ${selectList}\n${fromClause}${fragment.fromExtensions}`; + if (whereClause !== null) { + body += `\n${whereClause}`; + } + + return `${cteSection}${body}`; +} diff --git a/src/queryGenerator/treeWalker/types.ts b/src/queryGenerator/treeWalker/types.ts new file mode 100644 index 0000000..db39cf9 --- /dev/null +++ b/src/queryGenerator/treeWalker/types.ts @@ -0,0 +1,93 @@ +/** + * Core types for the tree-walker query generator. + * + * Each select-tree node produces a Fragment; sibling fragments are merged + * via partition-key joins. Context threads through descent, accumulating + * the current JSON source, partition keys, and scalar columns to bake into + * any enclosed recursive CTE. + */ + +import type { TranspilerContext } from "../../fhirpath/transpiler.js"; + +export type NodeKind = + | "ColumnsOnly" + | "Group" + | "ForEach" + | "ForEachOrNull" + | "Repeat" + | "UnionAll"; + +export interface PartitionKey { + /** Logical name, e.g. "id", "fe_0_key", "repeat_2_path". */ + name: string; + /** SQL expression that yields it in the *defining* scope. */ + sqlExpr: string; + /** Type used when projecting it through a CTE column list. */ + sqlType: string; +} + +export interface ScalarColumn { + /** Bracketed identifier, e.g. "[groupLinkId]". */ + name: string; + /** SQL expression evaluated in the outer scope, baked into a CTE anchor. */ + sqlExpr: string; +} + +export interface ProjectedColumn { + /** Bracketed identifier as it appears in the final SELECT list. */ + name: string; + sqlExpr: string; +} + +export interface CteDefinition { + alias: string; + /** Full SQL body without the "alias AS (...)" wrapper. */ + body: string; +} + +export type RowOrigin = "row" | "set"; + +export interface Context { + resourceAlias: string; + /** Current JSON source expression, e.g. "r.json", "forEach_0.value". */ + source: string; + /** Ordered, monotonically appended as the walker descends. */ + partitionKeys: PartitionKey[]; + /** Columns lexically captured above; baked into the next enclosed CTE anchor. */ + scalarColumns: ScalarColumn[]; + /** + * CROSS/OUTER APPLY chain accumulated above this point, needed so any + * enclosed Repeat CTE anchor can reach `ctx.source`. Each element starts + * with "\n". + */ + ancestorApplies: string; + /** True if any forEachOrNull ancestor is in scope. */ + nullable: boolean; + /** Shared mutable counter for unique CTE/alias names across the whole compile. */ + cteCounter: { value: number }; + /** Pass-through context for the FHIRPath transpiler. */ + transpilerCtx: TranspilerContext; +} + +export interface Fragment { + ctes: CteDefinition[]; + /** "FROM " or "" if the fragment inherits its FROM from the caller. */ + fromClause: string; + /** + * Ordered sequence of FROM-clause extensions: a mix of CROSS/OUTER APPLY + * and INNER/LEFT/FULL OUTER JOIN clauses, each prefixed by "\n". Order is + * preserved so that aliases are always introduced before they are + * referenced (e.g. an INNER JOIN to a Repeat CTE precedes any CROSS APPLY + * that reads from the CTE's `item_json`). + */ + fromExtensions: string; + columns: ProjectedColumn[]; + /** Keys exposed by this fragment for use by sibling joins. */ + partitionKeys: PartitionKey[]; + /** "set" = fragment exposes a CTE that participates in a join. */ + rowOrigin: RowOrigin; + /** Alias usable as a join anchor when rowOrigin === "set". */ + fromAlias?: string; + /** True if this fragment introduces a forEachOrNull (drives chooseJoinType). */ + nullableHere?: boolean; +} diff --git a/src/queryGenerator/treeWalker/walker.ts b/src/queryGenerator/treeWalker/walker.ts new file mode 100644 index 0000000..0845c54 --- /dev/null +++ b/src/queryGenerator/treeWalker/walker.ts @@ -0,0 +1,51 @@ +/** + * Recursive dispatch — visits a select-tree node and produces a Fragment. + * + * Each node kind has its own walker; this module just dispatches via + * classifyNode. Operators not yet implemented throw a clear error. + */ + +import type { ViewDefinitionSelect } from "../../types.js"; +import type { ColumnExpressionGenerator } from "../ColumnExpressionGenerator.js"; +import type { PathParser } from "../PathParser.js"; +import { classifyNode } from "./classify.js"; +import { walkColumnsOnly } from "./operators/columnsOnly.js"; +import { walkForEach } from "./operators/forEach.js"; +import { walkGroup } from "./operators/group.js"; +import { walkRepeat } from "./operators/repeat.js"; +import { walkUnionAll } from "./operators/unionAll.js"; +import type { Context, Fragment } from "./types.js"; + +export interface WalkerDeps { + columnGenerator: ColumnExpressionGenerator; + pathParser: PathParser; + schemaName: string; + tableName: string; +} + +export function makeWalker( + deps: WalkerDeps, +): (node: ViewDefinitionSelect, ctx: Context) => Fragment { + function walk(node: ViewDefinitionSelect, ctx: Context): Fragment { + const kind = classifyNode(node); + switch (kind) { + case "ColumnsOnly": + return walkColumnsOnly(node, ctx, deps.columnGenerator); + case "Group": + return walkGroup(node, ctx, walk, deps.columnGenerator); + case "ForEach": + case "ForEachOrNull": + return walkForEach(node, ctx, walk, { pathParser: deps.pathParser }); + case "Repeat": + return walkRepeat(node, ctx, walk, { + schemaName: deps.schemaName, + tableName: deps.tableName, + }); + case "UnionAll": + return walkUnionAll(node, ctx, walk, { + columnGenerator: deps.columnGenerator, + }); + } + } + return walk; +} From 5bd6f9202547d746844d34a051eba2c470c4e350 Mon Sep 17 00:00:00 2001 From: Piotr Szul Date: Sat, 2 May 2026 07:40:43 +1000 Subject: [PATCH 3/7] Fix Prettier formatting in treeWalker files to pass CI format check Co-Authored-By: Claude Sonnet 4.6 --- src/queryGenerator/treeWalker/compile.ts | 4 +--- src/queryGenerator/treeWalker/cteTemplates.ts | 6 +----- src/queryGenerator/treeWalker/operators/forEach.ts | 2 +- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/queryGenerator/treeWalker/compile.ts b/src/queryGenerator/treeWalker/compile.ts index 2ecd523..c375920 100644 --- a/src/queryGenerator/treeWalker/compile.ts +++ b/src/queryGenerator/treeWalker/compile.ts @@ -87,9 +87,7 @@ function buildRootContext( * Mirrors the behaviour of QueryGenerator.collectAllColumns so the public * TranspilationResult.columns shape is unchanged. */ -function collectColumnMetadata( - selects: ViewDefinitionSelect[], -): ColumnInfo[] { +function collectColumnMetadata(selects: ViewDefinitionSelect[]): ColumnInfo[] { const out: ColumnInfo[] = []; for (const select of selects) { if (select.column) { diff --git a/src/queryGenerator/treeWalker/cteTemplates.ts b/src/queryGenerator/treeWalker/cteTemplates.ts index b6ad150..06b1e9a 100644 --- a/src/queryGenerator/treeWalker/cteTemplates.ts +++ b/src/queryGenerator/treeWalker/cteTemplates.ts @@ -11,11 +11,7 @@ * contain dots in practice; not a blocker. */ -import type { - CteDefinition, - PartitionKey, - ScalarColumn, -} from "./types.js"; +import type { CteDefinition, PartitionKey, ScalarColumn } from "./types.js"; interface OpenJsonChain { applyClauses: string; diff --git a/src/queryGenerator/treeWalker/operators/forEach.ts b/src/queryGenerator/treeWalker/operators/forEach.ts index 6d49a22..98e9090 100644 --- a/src/queryGenerator/treeWalker/operators/forEach.ts +++ b/src/queryGenerator/treeWalker/operators/forEach.ts @@ -27,7 +27,7 @@ export function walkForEach( deps: ForEachDeps, ): Fragment { const isOrNull = node.forEachOrNull !== undefined; - const rawPath = (node.forEach ?? node.forEachOrNull) ?? ""; + const rawPath = node.forEach ?? node.forEachOrNull ?? ""; const applyType = isOrNull ? "OUTER APPLY" : "CROSS APPLY"; const alias = freshAlias(ctx, "forEach"); From 5d8f0835986aa6ef8d0e5cffa395b0e8e2621b1b Mon Sep 17 00:00:00 2001 From: Piotr Szul Date: Mon, 4 May 2026 19:26:08 +1000 Subject: [PATCH 4/7] Remove dead Fragment fields and ScalarColumn from tree-walker Drops fromClause, rowOrigin/RowOrigin, fromAlias, nullableHere, and the ScalarColumn/Context.scalarColumns machinery that was never populated. Also removes the stale USE_TREE_WALKER gate reference from index.ts. Co-Authored-By: Claude Sonnet 4.6 --- src/queryGenerator/treeWalker/compile.ts | 1 - src/queryGenerator/treeWalker/cteTemplates.ts | 25 +++---------------- src/queryGenerator/treeWalker/index.ts | 4 +-- .../treeWalker/mergeSiblings.ts | 6 ----- .../treeWalker/operators/columnsOnly.ts | 2 -- .../treeWalker/operators/forEach.ts | 1 - .../treeWalker/operators/group.ts | 2 -- .../treeWalker/operators/repeat.ts | 6 ----- .../treeWalker/operators/unionAll.ts | 5 ---- src/queryGenerator/treeWalker/render.ts | 5 +--- src/queryGenerator/treeWalker/types.ts | 22 +--------------- 11 files changed, 7 insertions(+), 72 deletions(-) diff --git a/src/queryGenerator/treeWalker/compile.ts b/src/queryGenerator/treeWalker/compile.ts index c375920..fc711f9 100644 --- a/src/queryGenerator/treeWalker/compile.ts +++ b/src/queryGenerator/treeWalker/compile.ts @@ -74,7 +74,6 @@ function buildRootContext( // pattern-matches on the source string). source: `${resourceAlias}.json`, partitionKeys: [idKey], - scalarColumns: [], ancestorApplies: "", nullable: false, cteCounter: { value: 0 }, diff --git a/src/queryGenerator/treeWalker/cteTemplates.ts b/src/queryGenerator/treeWalker/cteTemplates.ts index 06b1e9a..5d5458b 100644 --- a/src/queryGenerator/treeWalker/cteTemplates.ts +++ b/src/queryGenerator/treeWalker/cteTemplates.ts @@ -11,7 +11,7 @@ * contain dots in practice; not a blocker. */ -import type { CteDefinition, PartitionKey, ScalarColumn } from "./types.js"; +import type { CteDefinition, PartitionKey } from "./types.js"; interface OpenJsonChain { applyClauses: string; @@ -28,8 +28,6 @@ export interface BuildRepeatCteArgs { fromClause: string; /** CROSS/OUTER APPLY chain inherited from ancestors (each starts with "\n"). */ ancestorApplies: string; - /** Columns lexically captured above the repeat; baked into the anchor. */ - scalarColumns: ScalarColumn[]; /** Partition keys propagated through anchor and recursive members. */ partitionKeys: PartitionKey[]; /** Resource-level WHERE applied to the anchor (or null to omit). */ @@ -51,19 +49,12 @@ function buildAnchorMember(args: BuildRepeatCteArgs): string { source, fromClause, ancestorApplies, - scalarColumns, partitionKeys, resourcePredicate, } = args; - const keyProj = partitionKeys + const projLines = partitionKeys .map((k) => `${k.sqlExpr} AS [${k.name}]`) .join(",\n "); - const scalarProj = scalarColumns - .map((s) => `${s.sqlExpr} AS ${s.name}`) - .join(",\n "); - const projLines = [keyProj, scalarProj] - .filter((s) => s.length > 0) - .join(",\n "); const chain = buildOpenJsonChain(source, paths[0], "anchor"); const wherePart = resourcePredicate ? `\n ${resourcePredicate}` : ""; @@ -81,16 +72,8 @@ function buildRecursiveMember( path: string, index: number, ): string { - const { cteAlias, scalarColumns, partitionKeys } = args; - const propagatedKeyRefs = partitionKeys - .map((k) => `cte.[${k.name}]`) - .join(", "); - const propagatedScalarRefs = scalarColumns - .map((s) => `cte.${s.name}`) - .join(", "); - const head = [propagatedKeyRefs, propagatedScalarRefs] - .filter((s) => s.length > 0) - .join(", "); + const { cteAlias, partitionKeys } = args; + const head = partitionKeys.map((k) => `cte.[${k.name}]`).join(", "); const chain = buildOpenJsonChain("cte.item_json", path, `child_${index}`); return ` SELECT ${head}, diff --git a/src/queryGenerator/treeWalker/index.ts b/src/queryGenerator/treeWalker/index.ts index 6290e6f..b86e98d 100644 --- a/src/queryGenerator/treeWalker/index.ts +++ b/src/queryGenerator/treeWalker/index.ts @@ -1,5 +1,5 @@ /** - * Tree-walker query generator (greenfield, gated by USE_TREE_WALKER). + * Tree-walker query generator. */ export { compileViewDefinition } from "./compile.js"; @@ -13,9 +13,7 @@ export type { Context, Fragment, PartitionKey, - ScalarColumn, ProjectedColumn, CteDefinition, NodeKind, - RowOrigin, } from "./types.js"; diff --git a/src/queryGenerator/treeWalker/mergeSiblings.ts b/src/queryGenerator/treeWalker/mergeSiblings.ts index 741231e..5e8d240 100644 --- a/src/queryGenerator/treeWalker/mergeSiblings.ts +++ b/src/queryGenerator/treeWalker/mergeSiblings.ts @@ -16,11 +16,9 @@ export function mergeSiblings(fragments: Fragment[], ctx: Context): Fragment { if (fragments.length === 0) { return { ctes: [], - fromClause: "", fromExtensions: "", columns: [], partitionKeys: ctx.partitionKeys, - rowOrigin: "row", }; } @@ -29,7 +27,6 @@ export function mergeSiblings(fragments: Fragment[], ctx: Context): Fragment { const ctes = fragments.flatMap((f) => f.ctes); const fromExtensions = fragments.map((f) => f.fromExtensions).join(""); const columns = fragments.flatMap((f) => f.columns); - const nullableHere = fragments.some((f) => f.nullableHere); // Row siblings + zero or more set siblings: each set fragment already carries // its own INNER JOIN to its CTE, joined on the partition keys it inherited @@ -37,11 +34,8 @@ export function mergeSiblings(fragments: Fragment[], ctx: Context): Fragment { // keep their references to `r` valid. return { ctes, - fromClause: "", fromExtensions, columns, partitionKeys: ctx.partitionKeys, - rowOrigin: "row", - nullableHere, }; } diff --git a/src/queryGenerator/treeWalker/operators/columnsOnly.ts b/src/queryGenerator/treeWalker/operators/columnsOnly.ts index 8cbb4fe..92d84b5 100644 --- a/src/queryGenerator/treeWalker/operators/columnsOnly.ts +++ b/src/queryGenerator/treeWalker/operators/columnsOnly.ts @@ -31,10 +31,8 @@ export function walkColumnsOnly( : []; return { ctes: [], - fromClause: "", fromExtensions: "", columns, partitionKeys: ctx.partitionKeys, - rowOrigin: "row", }; } diff --git a/src/queryGenerator/treeWalker/operators/forEach.ts b/src/queryGenerator/treeWalker/operators/forEach.ts index 98e9090..3811b5c 100644 --- a/src/queryGenerator/treeWalker/operators/forEach.ts +++ b/src/queryGenerator/treeWalker/operators/forEach.ts @@ -51,7 +51,6 @@ export function walkForEach( return { ...inner, fromExtensions: applyClause + inner.fromExtensions, - nullableHere: isOrNull || inner.nullableHere, }; } diff --git a/src/queryGenerator/treeWalker/operators/group.ts b/src/queryGenerator/treeWalker/operators/group.ts index ad79e4f..3318077 100644 --- a/src/queryGenerator/treeWalker/operators/group.ts +++ b/src/queryGenerator/treeWalker/operators/group.ts @@ -33,11 +33,9 @@ export function walkGroup( if (children.length === 0) { return { ctes: [], - fromClause: "", fromExtensions: "", columns: [], partitionKeys: ctx.partitionKeys, - rowOrigin: "row", }; } diff --git a/src/queryGenerator/treeWalker/operators/repeat.ts b/src/queryGenerator/treeWalker/operators/repeat.ts index e827d5e..fff83e5 100644 --- a/src/queryGenerator/treeWalker/operators/repeat.ts +++ b/src/queryGenerator/treeWalker/operators/repeat.ts @@ -37,7 +37,6 @@ export function walkRepeat( source: ctx.source, fromClause: `FROM ${tableRef} AS [${ctx.resourceAlias}]`, ancestorApplies: ctx.ancestorApplies, - scalarColumns: ctx.scalarColumns, partitionKeys: ctx.partitionKeys, resourcePredicate: null, // Resource-level WHERE goes in the outer SELECT. }); @@ -53,15 +52,11 @@ export function walkRepeat( return { ctes: [cte, ...inner.ctes], - fromClause: "", // Join the CTE FIRST so subsequent applies/joins inside the repeat // (which reference `.item_json`) have the alias in scope. fromExtensions: joinClause + inner.fromExtensions, columns: inner.columns, partitionKeys: innerCtx.partitionKeys, - rowOrigin: "set", - fromAlias: cteAlias, - nullableHere: inner.nullableHere, }; } @@ -100,7 +95,6 @@ function buildRepeatInnerCtx( ...ctx, source: `${cteAlias}.item_json`, partitionKeys: [...ctx.partitionKeys, newKey], - scalarColumns: [], // Already baked into the CTE. ancestorApplies: ctx.ancestorApplies + joinClause, transpilerCtx: innerTranspilerCtx, }; diff --git a/src/queryGenerator/treeWalker/operators/unionAll.ts b/src/queryGenerator/treeWalker/operators/unionAll.ts index 14515e9..4e3ff92 100644 --- a/src/queryGenerator/treeWalker/operators/unionAll.ts +++ b/src/queryGenerator/treeWalker/operators/unionAll.ts @@ -67,12 +67,9 @@ function buildUnionAllFragment( })); return { ctes: allCtes, - fromClause: "", fromExtensions: `\nCROSS APPLY ${unionDerivedTable}`, columns: uaColumns, partitionKeys: ctx.partitionKeys, - rowOrigin: "row", - nullableHere: branchFragments.some((f) => f.nullableHere), }; } @@ -86,11 +83,9 @@ function collectOuterSiblings( if (node.column && node.column.length > 0) { out.push({ ctes: [], - fromClause: "", fromExtensions: "", columns: projectColumns(node.column, ctx, deps.columnGenerator), partitionKeys: ctx.partitionKeys, - rowOrigin: "row", }); } if (node.select) { diff --git a/src/queryGenerator/treeWalker/render.ts b/src/queryGenerator/treeWalker/render.ts index 29ecf28..595cec5 100644 --- a/src/queryGenerator/treeWalker/render.ts +++ b/src/queryGenerator/treeWalker/render.ts @@ -35,10 +35,7 @@ export function renderRoot( .map((c) => `${c.sqlExpr} AS [${c.name}]`) .join(",\n "); - const fromClause = - fragment.fromClause !== "" - ? fragment.fromClause - : `FROM ${tableRef} AS [${resourceAlias}]`; + const fromClause = `FROM ${tableRef} AS [${resourceAlias}]`; const whereClause = options.whereClauseBuilder.buildWhereClause( viewDef.resource, diff --git a/src/queryGenerator/treeWalker/types.ts b/src/queryGenerator/treeWalker/types.ts index db39cf9..c1f579d 100644 --- a/src/queryGenerator/treeWalker/types.ts +++ b/src/queryGenerator/treeWalker/types.ts @@ -3,8 +3,7 @@ * * Each select-tree node produces a Fragment; sibling fragments are merged * via partition-key joins. Context threads through descent, accumulating - * the current JSON source, partition keys, and scalar columns to bake into - * any enclosed recursive CTE. + * the current JSON source, partition keys, and APPLY chain. */ import type { TranspilerContext } from "../../fhirpath/transpiler.js"; @@ -26,13 +25,6 @@ export interface PartitionKey { sqlType: string; } -export interface ScalarColumn { - /** Bracketed identifier, e.g. "[groupLinkId]". */ - name: string; - /** SQL expression evaluated in the outer scope, baked into a CTE anchor. */ - sqlExpr: string; -} - export interface ProjectedColumn { /** Bracketed identifier as it appears in the final SELECT list. */ name: string; @@ -45,16 +37,12 @@ export interface CteDefinition { body: string; } -export type RowOrigin = "row" | "set"; - export interface Context { resourceAlias: string; /** Current JSON source expression, e.g. "r.json", "forEach_0.value". */ source: string; /** Ordered, monotonically appended as the walker descends. */ partitionKeys: PartitionKey[]; - /** Columns lexically captured above; baked into the next enclosed CTE anchor. */ - scalarColumns: ScalarColumn[]; /** * CROSS/OUTER APPLY chain accumulated above this point, needed so any * enclosed Repeat CTE anchor can reach `ctx.source`. Each element starts @@ -71,8 +59,6 @@ export interface Context { export interface Fragment { ctes: CteDefinition[]; - /** "FROM " or "" if the fragment inherits its FROM from the caller. */ - fromClause: string; /** * Ordered sequence of FROM-clause extensions: a mix of CROSS/OUTER APPLY * and INNER/LEFT/FULL OUTER JOIN clauses, each prefixed by "\n". Order is @@ -84,10 +70,4 @@ export interface Fragment { columns: ProjectedColumn[]; /** Keys exposed by this fragment for use by sibling joins. */ partitionKeys: PartitionKey[]; - /** "set" = fragment exposes a CTE that participates in a join. */ - rowOrigin: RowOrigin; - /** Alias usable as a join anchor when rowOrigin === "set". */ - fromAlias?: string; - /** True if this fragment introduces a forEachOrNull (drives chooseJoinType). */ - nullableHere?: boolean; } From 6730d32a34ba259bbd1d50500e552372b5de78b0 Mon Sep 17 00:00:00 2001 From: Piotr Szul Date: Mon, 4 May 2026 20:33:52 +1000 Subject: [PATCH 5/7] Simplify treeWalker: extract SQL type constants, hoist stateless helpers, trim barrel exports Co-Authored-By: Claude Sonnet 4.6 --- src/queryGenerator/treeWalker/compile.ts | 11 ++++++----- src/queryGenerator/treeWalker/index.ts | 13 ------------- src/queryGenerator/treeWalker/operators/forEach.ts | 3 ++- src/queryGenerator/treeWalker/operators/repeat.ts | 3 ++- src/queryGenerator/treeWalker/types.ts | 4 ++++ 5 files changed, 14 insertions(+), 20 deletions(-) diff --git a/src/queryGenerator/treeWalker/compile.ts b/src/queryGenerator/treeWalker/compile.ts index fc711f9..ba8ca40 100644 --- a/src/queryGenerator/treeWalker/compile.ts +++ b/src/queryGenerator/treeWalker/compile.ts @@ -17,8 +17,13 @@ import { PathParser } from "../PathParser.js"; import { WhereClauseBuilder } from "../WhereClauseBuilder.js"; import { renderRoot } from "./render.js"; import type { Context, PartitionKey } from "./types.js"; +import { SQL_INT } from "./types.js"; import { makeWalker } from "./walker.js"; +const columnGenerator = new ColumnExpressionGenerator(); +const pathParser = new PathParser(); +const whereClauseBuilder = new WhereClauseBuilder(); + export interface CompileOptions { tableName: string; schemaName: string; @@ -33,10 +38,6 @@ export function compileViewDefinition( const resourceAlias = options.transpilerCtx.resourceAlias; const ctx = buildRootContext(resourceAlias, options.transpilerCtx); - const columnGenerator = new ColumnExpressionGenerator(); - const whereClauseBuilder = new WhereClauseBuilder(); - const pathParser = new PathParser(); - const rootNode: ViewDefinitionSelect = { select: viewDef.select }; const walk = makeWalker({ columnGenerator, @@ -65,7 +66,7 @@ function buildRootContext( const idKey: PartitionKey = { name: "id", sqlExpr: `[${resourceAlias}].[id]`, - sqlType: "INT", + sqlType: SQL_INT, }; return { resourceAlias, diff --git a/src/queryGenerator/treeWalker/index.ts b/src/queryGenerator/treeWalker/index.ts index b86e98d..d98ea32 100644 --- a/src/queryGenerator/treeWalker/index.ts +++ b/src/queryGenerator/treeWalker/index.ts @@ -4,16 +4,3 @@ export { compileViewDefinition } from "./compile.js"; export type { CompileOptions } from "./compile.js"; -export { classifyNode } from "./classify.js"; -export { freshAlias } from "./aliasGenerator.js"; -export { makeWalker } from "./walker.js"; -export { mergeSiblings } from "./mergeSiblings.js"; -export { renderRoot } from "./render.js"; -export type { - Context, - Fragment, - PartitionKey, - ProjectedColumn, - CteDefinition, - NodeKind, -} from "./types.js"; diff --git a/src/queryGenerator/treeWalker/operators/forEach.ts b/src/queryGenerator/treeWalker/operators/forEach.ts index 3811b5c..e2c0a36 100644 --- a/src/queryGenerator/treeWalker/operators/forEach.ts +++ b/src/queryGenerator/treeWalker/operators/forEach.ts @@ -15,6 +15,7 @@ import type { ViewDefinitionSelect } from "../../../types.js"; import type { PathParser } from "../../PathParser.js"; import { freshAlias } from "../aliasGenerator.js"; import type { Context, Fragment, PartitionKey } from "../types.js"; +import { SQL_NVARCHAR_4000 } from "../types.js"; interface ForEachDeps { pathParser: PathParser; @@ -71,7 +72,7 @@ function buildInnerCtx( const innerKey: PartitionKey = { name: `${alias}_key`, sqlExpr: `${alias}.[key]`, - sqlType: "NVARCHAR(4000)", + sqlType: SQL_NVARCHAR_4000, }; return { ...ctx, diff --git a/src/queryGenerator/treeWalker/operators/repeat.ts b/src/queryGenerator/treeWalker/operators/repeat.ts index fff83e5..63b2bac 100644 --- a/src/queryGenerator/treeWalker/operators/repeat.ts +++ b/src/queryGenerator/treeWalker/operators/repeat.ts @@ -12,6 +12,7 @@ import type { ViewDefinitionSelect } from "../../../types.js"; import { freshAlias } from "../aliasGenerator.js"; import { buildRepeatCte } from "../cteTemplates.js"; import type { Context, Fragment, PartitionKey } from "../types.js"; +import { SQL_NVARCHAR_MAX } from "../types.js"; export interface RepeatDeps { schemaName: string; @@ -80,7 +81,7 @@ function buildRepeatInnerCtx( const newKey: PartitionKey = { name: `${cteAlias}_path`, sqlExpr: `${cteAlias}.__path`, - sqlType: "NVARCHAR(MAX)", + sqlType: SQL_NVARCHAR_MAX, }; const innerTranspilerCtx: TranspilerContext = { ...ctx.transpilerCtx, diff --git a/src/queryGenerator/treeWalker/types.ts b/src/queryGenerator/treeWalker/types.ts index c1f579d..5b475ae 100644 --- a/src/queryGenerator/treeWalker/types.ts +++ b/src/queryGenerator/treeWalker/types.ts @@ -8,6 +8,10 @@ import type { TranspilerContext } from "../../fhirpath/transpiler.js"; +export const SQL_INT = "INT"; +export const SQL_NVARCHAR_4000 = "NVARCHAR(4000)"; +export const SQL_NVARCHAR_MAX = "NVARCHAR(MAX)"; + export type NodeKind = | "ColumnsOnly" | "Group" From cf00a8efa20b904c42f30556483b54732e093508 Mon Sep 17 00:00:00 2001 From: Piotr Szul Date: Mon, 4 May 2026 20:37:25 +1000 Subject: [PATCH 6/7] Fix duplicate import lint errors from SQL type constant extraction Co-Authored-By: Claude Sonnet 4.6 --- src/queryGenerator/treeWalker/compile.ts | 3 +-- src/queryGenerator/treeWalker/operators/forEach.ts | 8 ++++++-- src/queryGenerator/treeWalker/operators/repeat.ts | 8 ++++++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/queryGenerator/treeWalker/compile.ts b/src/queryGenerator/treeWalker/compile.ts index ba8ca40..fde3172 100644 --- a/src/queryGenerator/treeWalker/compile.ts +++ b/src/queryGenerator/treeWalker/compile.ts @@ -16,8 +16,7 @@ import { ColumnExpressionGenerator } from "../ColumnExpressionGenerator.js"; import { PathParser } from "../PathParser.js"; import { WhereClauseBuilder } from "../WhereClauseBuilder.js"; import { renderRoot } from "./render.js"; -import type { Context, PartitionKey } from "./types.js"; -import { SQL_INT } from "./types.js"; +import { type Context, type PartitionKey, SQL_INT } from "./types.js"; import { makeWalker } from "./walker.js"; const columnGenerator = new ColumnExpressionGenerator(); diff --git a/src/queryGenerator/treeWalker/operators/forEach.ts b/src/queryGenerator/treeWalker/operators/forEach.ts index e2c0a36..2f1bcd7 100644 --- a/src/queryGenerator/treeWalker/operators/forEach.ts +++ b/src/queryGenerator/treeWalker/operators/forEach.ts @@ -14,8 +14,12 @@ import type { TranspilerContext } from "../../../fhirpath/transpiler.js"; import type { ViewDefinitionSelect } from "../../../types.js"; import type { PathParser } from "../../PathParser.js"; import { freshAlias } from "../aliasGenerator.js"; -import type { Context, Fragment, PartitionKey } from "../types.js"; -import { SQL_NVARCHAR_4000 } from "../types.js"; +import { + type Context, + type Fragment, + type PartitionKey, + SQL_NVARCHAR_4000, +} from "../types.js"; interface ForEachDeps { pathParser: PathParser; diff --git a/src/queryGenerator/treeWalker/operators/repeat.ts b/src/queryGenerator/treeWalker/operators/repeat.ts index 63b2bac..990be88 100644 --- a/src/queryGenerator/treeWalker/operators/repeat.ts +++ b/src/queryGenerator/treeWalker/operators/repeat.ts @@ -11,8 +11,12 @@ import type { TranspilerContext } from "../../../fhirpath/transpiler.js"; import type { ViewDefinitionSelect } from "../../../types.js"; import { freshAlias } from "../aliasGenerator.js"; import { buildRepeatCte } from "../cteTemplates.js"; -import type { Context, Fragment, PartitionKey } from "../types.js"; -import { SQL_NVARCHAR_MAX } from "../types.js"; +import { + type Context, + type Fragment, + type PartitionKey, + SQL_NVARCHAR_MAX, +} from "../types.js"; export interface RepeatDeps { schemaName: string; From efa9566426f3b041f1b06f9cafc09e77ab350aee Mon Sep 17 00:00:00 2001 From: Piotr Szul Date: Wed, 6 May 2026 11:36:08 +1000 Subject: [PATCH 7/7] Address johngrimes review: fix stale comments and add full JSDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix `joins` → `fromExtensions` in repeat.ts module comment - Fix render.ts template comment to show single `` slot - Rewrite mergeSiblings.ts header to describe actual behaviour - Fix unionAll.ts seed alias comment (_seed_X → _seed) with note on why no unique suffix is needed (UNION ALL branches have independent scopes) - Add @param/@returns/@throws JSDoc to all 12 exported treeWalker functions Co-Authored-By: Claude Sonnet 4.6 --- .../treeWalker/aliasGenerator.ts | 16 ++++++++ src/queryGenerator/treeWalker/classify.ts | 12 ++++++ src/queryGenerator/treeWalker/compile.ts | 13 +++++++ src/queryGenerator/treeWalker/cteTemplates.ts | 20 ++++++++++ .../treeWalker/mergeSiblings.ts | 28 ++++++++++---- .../treeWalker/operators/columnsOnly.ts | 30 +++++++++++++++ .../treeWalker/operators/forEach.ts | 25 +++++++++++++ .../treeWalker/operators/group.ts | 21 +++++++++++ .../treeWalker/operators/repeat.ts | 31 +++++++++++++++- .../treeWalker/operators/unionAll.ts | 37 ++++++++++++++++++- src/queryGenerator/treeWalker/render.ts | 17 ++++++++- src/queryGenerator/treeWalker/walker.ts | 18 +++++++++ 12 files changed, 257 insertions(+), 11 deletions(-) diff --git a/src/queryGenerator/treeWalker/aliasGenerator.ts b/src/queryGenerator/treeWalker/aliasGenerator.ts index 916b7ad..4964bfb 100644 --- a/src/queryGenerator/treeWalker/aliasGenerator.ts +++ b/src/queryGenerator/treeWalker/aliasGenerator.ts @@ -8,6 +8,22 @@ import type { Context } from "./types.js"; +/** + * Generates a globally unique SQL alias for the current compilation. + * + * Increments the shared `ctx.cteCounter` (a mutable reference threaded + * through every recursive walk call) and returns `"_"`. Because + * the counter is shared, aliases produced across different branches and depths + * of the tree are guaranteed to be distinct within a single + * `compileViewDefinition` call. + * + * @param ctx - The current walker context; its `cteCounter.value` is + * incremented as a side effect. + * @param prefix - A human-readable prefix that identifies the operator + * creating the alias (e.g. `"forEach"`, `"repeat"`, `"ua"`). + * @returns A string of the form `"_"` that is unique within the + * current compilation. + */ export function freshAlias(ctx: Context, prefix: string): string { const id = ctx.cteCounter.value++; return `${prefix}_${id}`; diff --git a/src/queryGenerator/treeWalker/classify.ts b/src/queryGenerator/treeWalker/classify.ts index d7b0468..c55f113 100644 --- a/src/queryGenerator/treeWalker/classify.ts +++ b/src/queryGenerator/treeWalker/classify.ts @@ -5,6 +5,18 @@ import type { ViewDefinitionSelect } from "../../types.js"; import type { NodeKind } from "./types.js"; +/** + * Classifies a ViewDefinition select node into one of the walker's NodeKinds. + * + * Operators are tested outer-to-inner: `forEachOrNull` first, then `forEach`, + * `repeat`, `unionAll`, `select` (Group), and finally `ColumnsOnly` as the + * base case. A node that carries multiple operator fields is classified as the + * outermost one; inner operators surface when the walker descends. + * + * @param node - The select node to classify. + * @returns The `NodeKind` string discriminant that determines which operator + * walker handles this node. + */ export function classifyNode(node: ViewDefinitionSelect): NodeKind { // Operators are ordered outer-to-inner. A node with multiple operators // (e.g. `{forEach, unionAll}`) is processed as the outer one; the inner diff --git a/src/queryGenerator/treeWalker/compile.ts b/src/queryGenerator/treeWalker/compile.ts index fde3172..5b2ad1f 100644 --- a/src/queryGenerator/treeWalker/compile.ts +++ b/src/queryGenerator/treeWalker/compile.ts @@ -30,6 +30,19 @@ export interface CompileOptions { transpilerCtx: TranspilerContext; } +/** + * Compiles a ViewDefinition into a T-SQL query string and column metadata. + * + * Wraps `viewDef.select` as a synthetic root Group node, walks the entire + * select tree to produce a Fragment, then renders that Fragment into a + * complete T-SQL statement (WITH … SELECT … FROM … WHERE). + * + * @param viewDef - The parsed ViewDefinition whose `select` tree is compiled. + * @param options - Compilation options including table/schema names, an + * optional test-isolation ID, and the FHIRPath transpiler context. + * @returns A `TranspilationResult` containing the generated SQL string and + * an ordered array of column metadata matching the view's output shape. + */ export function compileViewDefinition( viewDef: ViewDefinition, options: CompileOptions, diff --git a/src/queryGenerator/treeWalker/cteTemplates.ts b/src/queryGenerator/treeWalker/cteTemplates.ts index 5d5458b..05f80f1 100644 --- a/src/queryGenerator/treeWalker/cteTemplates.ts +++ b/src/queryGenerator/treeWalker/cteTemplates.ts @@ -34,6 +34,26 @@ export interface BuildRepeatCteArgs { resourcePredicate: string | null; } +/** + * Builds the SQL body of the recursive CTE used by the Repeat operator. + * + * Produces a `CteDefinition` whose body is an anchor SELECT followed by one + * `UNION ALL` recursive SELECT per path in `args.paths`. The anchor starts + * at the resource root and expands each array element via `OPENJSON`; each + * recursive member re-expands from the CTE's own `item_json` column, + * appending `.[key]` to the `__path` accumulator for stable per-element + * identity. Multi-segment paths (e.g. `"a.b.c"`) produce a chain of nested + * `CROSS APPLY OPENJSON` calls. + * + * @param args - Parameters controlling CTE generation: the CTE alias, the + * FHIRPath repeat paths, the JSON source expression for the anchor, the + * resource table FROM clause, any ancestor APPLY chain, the partition keys + * to propagate, and an optional resource-level WHERE predicate for the + * anchor. + * @returns A `CteDefinition` with `alias` set to `args.cteAlias` and `body` + * containing the full anchor + recursive SQL (without the outer + * `alias AS (...)` wrapper, which `renderRoot` adds). + */ export function buildRepeatCte(args: BuildRepeatCteArgs): CteDefinition { const anchor = buildAnchorMember(args); const recBlocks = args.paths.map((p, i) => buildRecursiveMember(args, p, i)); diff --git a/src/queryGenerator/treeWalker/mergeSiblings.ts b/src/queryGenerator/treeWalker/mergeSiblings.ts index 5e8d240..1ad85b3 100644 --- a/src/queryGenerator/treeWalker/mergeSiblings.ts +++ b/src/queryGenerator/treeWalker/mergeSiblings.ts @@ -1,17 +1,31 @@ /** * Merges sibling fragments produced by walking children of a Group. * - * Row-shaped siblings compose horizontally — APPLY chains and columns - * concatenate. Set-shaped siblings (Repeat CTEs) contribute INNER JOIN - * clauses to their CTEs; the outer FROM remains the resource table so row - * siblings can reference its scope. - * - * For multiple set siblings (Step 5 onward), the partition-key composite - * join logic ensures rows align correctly within the enclosing scope. + * Flattens CTE lists, concatenates `fromExtensions` strings and `columns` + * arrays in order, and passes `partitionKeys` through unchanged from `ctx`. */ import type { Context, Fragment } from "./types.js"; +/** + * Merges sibling Fragments produced by walking children of a Group node. + * + * Flattens each fragment's `ctes` list, concatenates `fromExtensions` strings + * (preserving order so aliases are introduced before they are referenced), and + * concatenates `columns` arrays in lexical order. `partitionKeys` are passed + * through unchanged from `ctx` — siblings share the same partition scope. + * + * Returns an empty Fragment (no CTEs, no extensions, no columns) when + * `fragments` is empty, and returns the sole fragment unchanged when only one + * is provided. + * + * @param fragments - Ordered array of sibling Fragments to merge. + * @param ctx - The context of the parent Group node, used to supply + * `partitionKeys` for the merged result and as the base for the empty-array + * case. + * @returns A single merged Fragment whose columns, CTEs, and FROM extensions + * are the ordered union of all input fragments. + */ export function mergeSiblings(fragments: Fragment[], ctx: Context): Fragment { if (fragments.length === 0) { return { diff --git a/src/queryGenerator/treeWalker/operators/columnsOnly.ts b/src/queryGenerator/treeWalker/operators/columnsOnly.ts index 92d84b5..82c72ae 100644 --- a/src/queryGenerator/treeWalker/operators/columnsOnly.ts +++ b/src/queryGenerator/treeWalker/operators/columnsOnly.ts @@ -10,6 +10,21 @@ import type { import type { ColumnExpressionGenerator } from "../../ColumnExpressionGenerator.js"; import type { Context, Fragment, ProjectedColumn } from "../types.js"; +/** + * Projects an array of ViewDefinition column descriptors into SQL expressions. + * + * Delegates expression generation to `ColumnExpressionGenerator`, which + * translates each column's FHIRPath expression into a T-SQL expression + * relative to the current transpiler context (iteration source, aliases, etc.). + * + * @param columns - The column descriptors from the ViewDefinition select node. + * @param ctx - The current walker context supplying the transpiler context + * used for FHIRPath-to-SQL translation. + * @param columnGenerator - The generator that converts each column descriptor + * into its SQL expression string. + * @returns An ordered array of `ProjectedColumn` objects, each pairing the + * column's logical name with its SQL expression. + */ export function projectColumns( columns: ViewDefinitionColumn[], ctx: Context, @@ -21,6 +36,21 @@ export function projectColumns( })); } +/** + * Walker for ColumnsOnly nodes — emits the projected columns for a leaf select node. + * + * Produces a Fragment with no CTEs, no FROM extensions, and columns derived + * from `node.column[]` via `projectColumns`. If `node.column` is absent or + * empty, the returned Fragment has an empty columns array. + * + * @param node - The leaf select node whose `column[]` array is projected. + * @param ctx - The current walker context supplying partition keys and the + * transpiler context for expression generation. + * @param columnGenerator - The generator that converts column descriptors into + * T-SQL expressions. + * @returns A Fragment carrying only projected columns; `ctes` and + * `fromExtensions` are always empty. + */ export function walkColumnsOnly( node: ViewDefinitionSelect, ctx: Context, diff --git a/src/queryGenerator/treeWalker/operators/forEach.ts b/src/queryGenerator/treeWalker/operators/forEach.ts index 2f1bcd7..7d64425 100644 --- a/src/queryGenerator/treeWalker/operators/forEach.ts +++ b/src/queryGenerator/treeWalker/operators/forEach.ts @@ -25,6 +25,31 @@ interface ForEachDeps { pathParser: PathParser; } +/** + * Walker for ForEach and ForEachOrNull nodes. + * + * Emits a `CROSS APPLY` (or `OUTER APPLY` for `forEachOrNull`) clause that + * iterates over a JSON array identified by the node's FHIRPath expression. + * Updates `ctx.source`, `ctx.transpilerCtx`, and `ctx.partitionKeys` so that + * child nodes project columns relative to each iteration value, then walks + * the child sub-tree (`column`, `select`, `unionAll`) in that inner context. + * The apply clause is prepended to the inner Fragment's `fromExtensions`. + * + * Path features handled via `PathParser`: `.where()` filters, `.first()` + * selectors, array indexing, and multi-segment array flattening (nested + * `CROSS APPLY OPENJSON` chains). + * + * @param node - The ForEach or ForEachOrNull select node. `node.forEach` or + * `node.forEachOrNull` supplies the FHIRPath expression to iterate. + * @param ctx - The current walker context; the inner context is derived from + * it by updating `source`, `partitionKeys`, `ancestorApplies`, `nullable`, + * and `transpilerCtx`. + * @param walk - The recursive walk function used to visit the inner sub-tree. + * @param deps - Dependencies containing the `PathParser` used to parse and + * decompose the FHIRPath expression. + * @returns A Fragment whose `fromExtensions` begins with the generated APPLY + * clause followed by any extensions produced by the inner walk. + */ export function walkForEach( node: ViewDefinitionSelect, ctx: Context, diff --git a/src/queryGenerator/treeWalker/operators/group.ts b/src/queryGenerator/treeWalker/operators/group.ts index 3318077..4129d8d 100644 --- a/src/queryGenerator/treeWalker/operators/group.ts +++ b/src/queryGenerator/treeWalker/operators/group.ts @@ -12,6 +12,27 @@ import { mergeSiblings } from "../mergeSiblings.js"; import type { Context, Fragment } from "../types.js"; import { projectColumns, walkColumnsOnly } from "./columnsOnly.js"; +/** + * Walker for Group nodes — visits each child select and merges their Fragments. + * + * If the node has both `column[]` and `select[]`, the columns are emitted as + * an implicit first ColumnsOnly child so they appear before the child select + * projections in lexical order. All children are walked with the same + * unmodified `ctx`; their Fragments are merged via `mergeSiblings`. + * + * Returns an empty Fragment when the node has neither `column[]` nor + * `select[]` children. + * + * @param node - The Group select node, which may carry `column[]`, `select[]`, + * or both. + * @param ctx - The current walker context passed unchanged to every child. + * @param walk - The recursive walk function used to visit each child select + * node. + * @param columnGenerator - The generator forwarded to `walkColumnsOnly` when + * the node also carries inline `column[]` entries. + * @returns A merged Fragment whose columns, CTEs, and FROM extensions are the + * ordered union of all child Fragments. + */ export function walkGroup( node: ViewDefinitionSelect, ctx: Context, diff --git a/src/queryGenerator/treeWalker/operators/repeat.ts b/src/queryGenerator/treeWalker/operators/repeat.ts index 990be88..e67c72c 100644 --- a/src/queryGenerator/treeWalker/operators/repeat.ts +++ b/src/queryGenerator/treeWalker/operators/repeat.ts @@ -3,7 +3,7 @@ * * The CTE projects the current partition keys and any baked scalar columns * plus a content-derived `__path` for stable identity across re-evaluations. - * The Fragment's `joins` is an INNER JOIN to the CTE on the partition key + * The Fragment's `fromExtensions` is an INNER JOIN to the CTE on the partition key * `[id]`; sibling-level composite-key joins are added by `mergeSiblings`. */ @@ -23,6 +23,35 @@ export interface RepeatDeps { tableName: string; } +/** + * Walker for Repeat nodes — emits a recursive CTE and returns a set Fragment. + * + * Generates a `WITH RECURSIVE`-style CTE (via `buildRepeatCte`) whose anchor + * member starts at the resource root and whose recursive members re-expand + * each element's `item_json` using the same path list. The CTE accumulates a + * `__path` string for stable per-element identity across recursion levels. + * + * The returned Fragment contains the CTE plus an `INNER JOIN` to it in + * `fromExtensions`; the join is keyed on all current partition keys so that + * only rows belonging to the enclosing resource (and any ancestor forEach) + * are included. The inner context updates `source` to `.item_json` + * and appends a new `_path` partition key so that nested operators + * can further partition the result set. + * + * @param node - The Repeat select node; `node.repeat` supplies the ordered + * list of FHIRPath strings used as the anchor and recursive paths. + * @param ctx - The current walker context; the inner context is derived from + * it by updating `source`, `partitionKeys`, `ancestorApplies`, and + * `transpilerCtx`. + * @param walk - The recursive walk function used to visit the inner sub-tree + * (`column`, `select`, `unionAll`) in the repeat-item context. + * @param deps - Schema and table name needed to construct the resource FROM + * clause inside the CTE anchor. + * @returns A Fragment whose `ctes` list begins with the recursive CTE, + * `fromExtensions` begins with the INNER JOIN to that CTE, and `columns` + * are those produced by the inner walk. + * @throws {Error} When `node.repeat` is absent or empty. + */ export function walkRepeat( node: ViewDefinitionSelect, ctx: Context, diff --git a/src/queryGenerator/treeWalker/operators/unionAll.ts b/src/queryGenerator/treeWalker/operators/unionAll.ts index 4e3ff92..0f963ea 100644 --- a/src/queryGenerator/treeWalker/operators/unionAll.ts +++ b/src/queryGenerator/treeWalker/operators/unionAll.ts @@ -30,6 +30,37 @@ export interface UnionAllDeps { columnGenerator: ColumnExpressionGenerator; } +/** + * Walker for UnionAll nodes. + * + * Walks each branch of `node.unionAll` in the current context and assembles + * them into a `CROSS APPLY (...) AS ua_` derived table where each branch + * contributes a `SELECT … FROM (SELECT 1 AS _) AS _seed …` statement joined + * by `UNION ALL`. Branch columns are aligned positionally to branch[0]'s + * names, satisfying the SoF spec requirement that all branches produce the + * same column names and types. + * + * CTEs from any branch (e.g. produced by enclosed Repeat nodes) bubble up + * into the returned Fragment's `ctes` list so they appear in the top-level + * `WITH` clause. + * + * If the same node also carries `column[]` or `select[]` alongside + * `unionAll`, those are emitted as outer siblings in the enclosing SELECT + * scope and merged with the union fragment via `mergeSiblings`. + * + * @param node - The UnionAll select node; `node.unionAll` must be a non-empty + * array of branch select nodes. + * @param ctx - The current walker context, passed unchanged to every branch + * and to any sibling column/select processing. + * @param walk - The recursive walk function used to visit each branch and any + * outer sibling select nodes. + * @param deps - Dependencies containing the `ColumnExpressionGenerator` used + * to project any outer sibling `column[]` entries. + * @returns A Fragment whose `fromExtensions` contains the `CROSS APPLY` + * derived-table clause, `ctes` aggregates all branch CTEs, and `columns` + * contains the outer projection (plus any sibling columns). + * @throws {Error} When `node.unionAll` is empty. + */ export function walkUnionAll( node: ViewDefinitionSelect, ctx: Context, @@ -97,9 +128,11 @@ function collectOuterSiblings( /** * Renders one branch's SELECT inside the unionAll derived table. * - * The seed `(SELECT 1 AS _) AS _seed_X` ensures the branch always has a + * The seed `(SELECT 1 AS _) AS _seed` ensures the branch always has a * FROM clause to attach its CROSS/OUTER APPLY chain to, and keeps the SQL - * uniform whether the branch has operators or not. + * uniform whether the branch has operators or not. No unique suffix is + * needed because each SELECT in a UNION ALL has its own independent FROM + * scope — `_seed` in one branch cannot collide with `_seed` in another. * * Columns are re-aliased to branch[0]'s names so the outer projection by * name works for every branch row. diff --git a/src/queryGenerator/treeWalker/render.ts b/src/queryGenerator/treeWalker/render.ts index 595cec5..2028568 100644 --- a/src/queryGenerator/treeWalker/render.ts +++ b/src/queryGenerator/treeWalker/render.ts @@ -1,7 +1,7 @@ /** * Renders a root Fragment into the final T-SQL statement: * - * WITH SELECT FROM WHERE + * WITH SELECT FROM WHERE */ import type { TranspilerContext } from "../../fhirpath/transpiler.js"; @@ -18,6 +18,21 @@ export interface RenderOptions { transpilerCtx: TranspilerContext; } +/** + * Renders a root Fragment into the final T-SQL statement. + * + * Assembles the optional `WITH ` preamble, the `SELECT ` list, + * the `FROM
` clause, any `fromExtensions` (APPLY / JOIN chains), and + * the optional `WHERE` predicate built by `WhereClauseBuilder`. + * + * @param fragment - The root Fragment produced by walking the select tree. + * @param viewDef - The ViewDefinition supplying the resource type, WHERE + * predicates, and other metadata needed to construct the WHERE clause. + * @param options - Render options including table/schema names, resource alias, + * optional test-isolation ID, the where-clause builder, and the transpiler + * context. + * @returns The complete T-SQL query string ready for execution. + */ export function renderRoot( fragment: Fragment, viewDef: ViewDefinition, diff --git a/src/queryGenerator/treeWalker/walker.ts b/src/queryGenerator/treeWalker/walker.ts index 0845c54..2265cb8 100644 --- a/src/queryGenerator/treeWalker/walker.ts +++ b/src/queryGenerator/treeWalker/walker.ts @@ -23,6 +23,24 @@ export interface WalkerDeps { tableName: string; } +/** + * Creates the recursive walk function used to traverse the select tree. + * + * The returned `walk` function classifies each `ViewDefinitionSelect` node via + * `classifyNode` and dispatches to the appropriate operator walker + * (`walkColumnsOnly`, `walkGroup`, `walkForEach`, `walkRepeat`, or + * `walkUnionAll`). The same `walk` reference is threaded into every operator + * so they can recurse into child nodes without circular imports. + * + * @param deps - External service dependencies shared across all operator + * walkers: a `ColumnExpressionGenerator`, a `PathParser`, and the target + * schema/table names used by the Repeat CTE builder. + * @returns A `walk` function that accepts a node and a `Context` and returns + * the corresponding `Fragment`. + * @throws {Error} When a node's kind is not handled (should not occur given + * the exhaustive `classifyNode` discriminants, but TypeScript exhaustiveness + * checking requires the switch to be complete). + */ export function makeWalker( deps: WalkerDeps, ): (node: ViewDefinitionSelect, ctx: Context) => Fragment {