Skip to content

Commit b6b64f9

Browse files
committed
feat: implement FormulaExpansionVisitor for expanding formula field references
1 parent 59b8eb3 commit b6b64f9

File tree

4 files changed

+264
-15
lines changed

4 files changed

+264
-15
lines changed

apps/nestjs-backend/src/features/field/formula-expansion.service.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Injectable } from '@nestjs/common';
2-
import { FieldType, FormulaFieldCore } from '@teable/core';
3-
import type { IFormulaFieldOptions } from '@teable/core';
2+
import { FieldType, FormulaFieldCore, FormulaExpansionVisitor } from '@teable/core';
3+
import type { IFormulaFieldOptions, IFieldExpansionMap } from '@teable/core';
44

55
export interface IFieldForExpansion {
66
id: string;
@@ -27,6 +27,9 @@ export class FormulaExpansionService {
2727
* When a formula field references another formula field with dbGenerated=true, instead of referencing
2828
* the generated column name, we expand and substitute the original expression directly.
2929
*
30+
* Uses FormulaExpansionVisitor to traverse the parsed AST and replace field references, ensuring
31+
* consistency with the grammar definition and avoiding regex pattern duplication.
32+
*
3033
* @example
3134
* ```typescript
3235
* // Given these fields:
@@ -53,17 +56,15 @@ export class FormulaExpansionService {
5356
// Get all field references in this expression
5457
const referencedFieldIds = FormulaFieldCore.getReferenceFieldIds(expression);
5558

56-
let result = expression;
59+
// Build expansion map for the visitor
60+
const expansionMap: IFieldExpansionMap = {};
5761

58-
// Replace each field reference
5962
for (const fieldId of referencedFieldIds) {
6063
const field = context.fieldMap[fieldId];
6164
if (!field) {
6265
throw new Error(`Referenced field not found: ${fieldId}`);
6366
}
6467

65-
let replacement: string;
66-
6768
if (field.type === FieldType.Formula) {
6869
// Check for circular references
6970
if (context.expansionStack.has(fieldId)) {
@@ -73,20 +74,19 @@ export class FormulaExpansionService {
7374
// Get the expanded expression for this formula field
7475
const expandedExpression = this.getExpandedExpressionForField(fieldId, context);
7576

76-
// Wrap in parentheses to maintain precedence
77-
replacement = `(${expandedExpression})`;
77+
// Wrap in parentheses to maintain precedence and add to expansion map
78+
expansionMap[fieldId] = `(${expandedExpression})`;
7879
} else {
7980
// For non-formula fields, keep as field reference (will be converted to SQL later)
80-
replacement = `{${fieldId}}`;
81+
expansionMap[fieldId] = `{${fieldId}}`;
8182
}
82-
83-
// TODO: create a new visitor to handle field reference
84-
// Replace all occurrences of this field reference
85-
const fieldRefPattern = new RegExp(`\\{${fieldId}\\}`, 'g');
86-
result = result.replace(fieldRefPattern, replacement);
8783
}
8884

89-
return result;
85+
// Use the visitor to perform the expansion
86+
const tree = FormulaFieldCore.parse(expression);
87+
const visitor = new FormulaExpansionVisitor(expansionMap);
88+
visitor.visit(tree);
89+
return visitor.getResult();
9090
} catch (error: unknown) {
9191
const message = error instanceof Error ? error.message : String(error);
9292
throw new Error(`Failed to expand formula expression "${expression}": ${message}`);
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/* eslint-disable sonarjs/no-duplicate-string */
2+
import { describe, it, expect } from 'vitest';
3+
import { FormulaFieldCore } from '../models/field/derivate/formula.field';
4+
import { FormulaExpansionVisitor, type IFieldExpansionMap } from './expansion.visitor';
5+
6+
describe('FormulaExpansionVisitor', () => {
7+
const parseAndExpand = (expression: string, expansionMap: IFieldExpansionMap): string => {
8+
const tree = FormulaFieldCore.parse(expression);
9+
const visitor = new FormulaExpansionVisitor(expansionMap);
10+
visitor.visit(tree);
11+
return visitor.getResult();
12+
};
13+
14+
describe('basic field reference expansion', () => {
15+
it('should expand a single field reference', () => {
16+
const expansionMap = {
17+
field1: 'expanded_field1',
18+
};
19+
20+
const result = parseAndExpand('{field1}', expansionMap);
21+
expect(result).toBe('expanded_field1');
22+
});
23+
24+
it('should expand field references in expressions', () => {
25+
const expansionMap = {
26+
field1: '(base_field + 10)',
27+
};
28+
29+
const result = parseAndExpand('{field1} * 2', expansionMap);
30+
expect(result).toBe('(base_field + 10) * 2');
31+
});
32+
33+
it('should expand multiple field references', () => {
34+
const expansionMap = {
35+
field1: 'expanded_field1',
36+
field2: 'expanded_field2',
37+
};
38+
39+
const result = parseAndExpand('{field1} + {field2}', expansionMap);
40+
expect(result).toBe('expanded_field1 + expanded_field2');
41+
});
42+
});
43+
44+
describe('complex expressions', () => {
45+
it('should handle nested parentheses in expansions', () => {
46+
const expansionMap = {
47+
field1: '((base + 5) * 2)',
48+
field2: '(other - 1)',
49+
};
50+
51+
const result = parseAndExpand('({field1} + {field2}) / 3', expansionMap);
52+
expect(result).toBe('(((base + 5) * 2) + (other - 1)) / 3');
53+
});
54+
55+
it('should handle function calls with expanded fields', () => {
56+
const expansionMap = {
57+
field1: 'SUM(column1)',
58+
field2: 'AVG(column2)',
59+
};
60+
61+
const result = parseAndExpand('MAX({field1}, {field2})', expansionMap);
62+
expect(result).toBe('MAX(SUM(column1), AVG(column2))');
63+
});
64+
65+
it('should handle string literals mixed with field references', () => {
66+
const expansionMap = {
67+
field1: 'user_name',
68+
};
69+
70+
const result = parseAndExpand('"Hello " + {field1} + "!"', expansionMap);
71+
expect(result).toBe('"Hello " + user_name + "!"');
72+
});
73+
});
74+
75+
describe('edge cases', () => {
76+
it('should preserve field references without expansions', () => {
77+
const expansionMap = {
78+
field1: 'expanded_field1',
79+
};
80+
81+
const result = parseAndExpand('{field1} + {field2}', expansionMap);
82+
expect(result).toBe('expanded_field1 + {field2}');
83+
});
84+
85+
it('should handle empty expansion map', () => {
86+
const expansionMap = {};
87+
88+
const result = parseAndExpand('{field1} + {field2}', expansionMap);
89+
expect(result).toBe('{field1} + {field2}');
90+
});
91+
92+
it('should handle expressions without field references', () => {
93+
const expansionMap = {
94+
field1: 'expanded_field1',
95+
};
96+
97+
const result = parseAndExpand('1 + 2 * 3', expansionMap);
98+
expect(result).toBe('1 + 2 * 3');
99+
});
100+
101+
it('should handle field references in complex nested expressions', () => {
102+
const expansionMap = {
103+
a: '(x + y)',
104+
b: '(z * 2)',
105+
};
106+
107+
const result = parseAndExpand('IF({a} > 0, {b}, -{b})', expansionMap);
108+
expect(result).toBe('IF((x + y) > 0, (z * 2), -(z * 2))');
109+
});
110+
});
111+
112+
describe('visitor reuse', () => {
113+
it('should allow visitor reuse with reset', () => {
114+
const visitor = new FormulaExpansionVisitor({ field1: 'expanded' });
115+
116+
// First use
117+
const tree1 = FormulaFieldCore.parse('{field1} + 1');
118+
visitor.visit(tree1);
119+
const result1 = visitor.getResult();
120+
expect(result1).toBe('expanded + 1');
121+
122+
// Reset and reuse
123+
visitor.reset();
124+
const tree2 = FormulaFieldCore.parse('{field1} * 2');
125+
visitor.visit(tree2);
126+
const result2 = visitor.getResult();
127+
expect(result2).toBe('expanded * 2');
128+
});
129+
});
130+
131+
describe('real-world formula expansion scenarios', () => {
132+
it('should handle the JSDoc example scenario', () => {
133+
// Simulates the scenario described in FormulaExpansionService JSDoc
134+
const expansionMap = {
135+
field2: '({field1} + 10)',
136+
};
137+
138+
const result = parseAndExpand('{field2} * 2', expansionMap);
139+
expect(result).toBe('({field1} + 10) * 2');
140+
});
141+
142+
it('should handle nested formula expansion', () => {
143+
// field1 -> field2 -> field3 expansion chain
144+
const expansionMap = {
145+
field2: '({field1} + 10)',
146+
field3: '(({field1} + 10) * 2)',
147+
};
148+
149+
const result = parseAndExpand('{field3} + 5', expansionMap);
150+
expect(result).toBe('(({field1} + 10) * 2) + 5');
151+
});
152+
});
153+
});
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { AbstractParseTreeVisitor } from 'antlr4ts/tree/AbstractParseTreeVisitor';
2+
import type { TerminalNode } from 'antlr4ts/tree/TerminalNode';
3+
import type { FieldReferenceCurlyContext } from './parser/Formula';
4+
5+
/**
6+
* Interface for field expansion mapping
7+
*/
8+
export interface IFieldExpansionMap {
9+
[fieldId: string]: string;
10+
}
11+
12+
/**
13+
* A visitor that expands formula field references by replacing them with their expanded expressions.
14+
*
15+
* This visitor traverses the parsed formula AST and replaces field references ({fieldId}) with
16+
* their corresponding expanded expressions. It's designed to handle formula expansion for
17+
* avoiding PostgreSQL generated column limitations.
18+
*
19+
* @example
20+
* ```typescript
21+
* // Given expansion map: { 'field2': '({field1} + 10)' }
22+
* // Input formula: '{field2} * 2'
23+
* // Output: '({field1} + 10) * 2'
24+
*
25+
* const expansionMap = { 'field2': '({field1} + 10)' };
26+
* const visitor = new FormulaExpansionVisitor(expansionMap);
27+
* visitor.visit(parsedTree);
28+
* const result = visitor.getResult(); // '({field1} + 10) * 2'
29+
* ```
30+
*/
31+
export class FormulaExpansionVisitor extends AbstractParseTreeVisitor<void> {
32+
private result = '';
33+
private readonly expansionMap: IFieldExpansionMap;
34+
35+
constructor(expansionMap: IFieldExpansionMap) {
36+
super();
37+
this.expansionMap = expansionMap;
38+
}
39+
40+
defaultResult() {
41+
return undefined;
42+
}
43+
44+
/**
45+
* Handles field reference nodes in the AST
46+
* @param ctx The field reference context from ANTLR
47+
*/
48+
visitFieldReferenceCurly(ctx: FieldReferenceCurlyContext) {
49+
const originalText = ctx.text;
50+
51+
// Extract field ID from {fieldId} format
52+
// The ANTLR grammar defines IDENTIFIER_VARIABLE as '{' .*? '}'
53+
let fieldId = originalText;
54+
if (originalText.startsWith('{') && originalText.endsWith('}')) {
55+
fieldId = originalText.slice(1, -1);
56+
}
57+
58+
// Check if we have an expansion for this field
59+
const expansion = this.expansionMap[fieldId];
60+
if (expansion !== undefined) {
61+
// Use the expanded expression
62+
this.result += expansion;
63+
} else {
64+
// Keep the original field reference if no expansion is available
65+
this.result += originalText;
66+
}
67+
}
68+
69+
/**
70+
* Handles terminal nodes (tokens) in the AST
71+
* @param node The terminal node
72+
*/
73+
visitTerminal(node: TerminalNode) {
74+
const text = node.text;
75+
if (text === '<EOF>') {
76+
return;
77+
}
78+
this.result += text;
79+
}
80+
81+
/**
82+
* Gets the final expanded formula result
83+
* @returns The formula with field references expanded
84+
*/
85+
getResult(): string {
86+
return this.result;
87+
}
88+
89+
/**
90+
* Resets the visitor state for reuse
91+
*/
92+
reset(): void {
93+
this.result = '';
94+
}
95+
}

packages/core/src/formula/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export * from './typed-value';
33
export * from './visitor';
44
export * from './field-reference.visitor';
55
export * from './conversion.visitor';
6+
export * from './expansion.visitor';
67
export * from './sql-conversion.visitor';
78
export * from './parse-formula';
89
export { FunctionName, FormulaFuncType } from './functions/common';

0 commit comments

Comments
 (0)