Skip to content

Commit 59b8eb3

Browse files
committed
feat: implement formula expansion service and related tests
- Add FormulaExpansionService to handle expansion of formula expressions, allowing for nested and circular references - Create integration tests for FormulaExpansionService to validate expansion logic and error handling - Introduce utility functions for managing generated column names, including naming conventions and extraction of original field names - Update SQL conversion visitor to support context-aware field references - Enhance formula field handling in various services, ensuring proper expansion and reference management - Add comprehensive tests for generated column utilities and formula field references
1 parent 895e957 commit 59b8eb3

19 files changed

+1348
-39
lines changed

apps/nestjs-backend/src/db-provider/formula-query/formula-query.abstract.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,7 @@ export abstract class FormulaQueryAbstract implements IFormulaQueryInterface {
164164
}
165165

166166
// Field Reference - Common implementation
167-
fieldReference(fieldId: string, columnName: string): string {
168-
return columnName;
169-
}
167+
abstract fieldReference(fieldId: string, columnName: string): string;
170168

171169
// Literals - Common implementations
172170
stringLiteral(value: string): string {

apps/nestjs-backend/src/db-provider/formula-query/formula-query.interface.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export interface IFormulaQueryInterface {
124124
unaryMinus(value: string): string;
125125

126126
// Field Reference
127-
fieldReference(fieldId: string, columnName: string): string;
127+
fieldReference(fieldId: string, columnName: string, context?: IFormulaConversionContext): string;
128128

129129
// Literals
130130
stringLiteral(value: string): string;
@@ -150,7 +150,14 @@ export interface IFormulaQueryInterface {
150150
* Context information for formula conversion
151151
*/
152152
export interface IFormulaConversionContext {
153-
fieldMap: { [fieldId: string]: { columnName: string } };
153+
fieldMap: {
154+
[fieldId: string]: {
155+
columnName: string;
156+
fieldType?: string;
157+
dbGenerated?: boolean;
158+
expandedExpression?: string;
159+
};
160+
};
154161
timeZone?: string;
155162
}
156163

apps/nestjs-backend/src/db-provider/formula-query/postgres/formula-query.postgres.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { FormulaQueryAbstract } from '../formula-query.abstract';
2+
import type { IFormulaConversionContext } from '../formula-query.interface';
23

34
/**
45
* PostgreSQL-specific implementation of formula functions
@@ -435,7 +436,13 @@ export class FormulaQueryPostgres extends FormulaQueryAbstract {
435436
}
436437

437438
// Field Reference - PostgreSQL uses double quotes for identifiers
438-
fieldReference(fieldId: string, columnName: string): string {
439+
fieldReference(
440+
_fieldId: string,
441+
columnName: string,
442+
_context?: IFormulaConversionContext
443+
): string {
444+
// For regular field references, return the column reference
445+
// Note: Expansion is handled at the expression level, not at individual field reference level
439446
return `"${columnName}"`;
440447
}
441448

apps/nestjs-backend/src/db-provider/formula-query/sqlite/formula-query.sqlite.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { FormulaQueryAbstract } from '../formula-query.abstract';
2+
import type { IFormulaConversionContext } from '../formula-query.interface';
23

34
/**
45
* SQLite-specific implementation of formula functions
@@ -435,7 +436,13 @@ export class FormulaQuerySqlite extends FormulaQueryAbstract {
435436
}
436437

437438
// Field Reference - SQLite uses backticks for identifiers
438-
fieldReference(fieldId: string, columnName: string): string {
439+
fieldReference(
440+
_fieldId: string,
441+
columnName: string,
442+
_context?: IFormulaConversionContext
443+
): string {
444+
// For regular field references, return the column reference
445+
// Note: Expansion is handled at the expression level, not at individual field reference level
439446
return `\`${columnName}\``;
440447
}
441448

apps/nestjs-backend/src/features/field/database-column-visitor.postgres.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import type {
1919
UserFieldCore,
2020
IFieldVisitor,
2121
} from '@teable/core';
22-
import { DbFieldType, CellValueType } from '@teable/core';
22+
import { DbFieldType } from '@teable/core';
2323
import type { Knex } from 'knex';
2424
import type { IDbProvider } from '../../db-provider/db.provider.interface';
2525
import type { IFormulaConversionContext } from '../../db-provider/formula-query/formula-query.interface';
@@ -43,7 +43,12 @@ export interface IDatabaseColumnContext {
4343
dbProvider?: IDbProvider;
4444
/** Field map for formula conversion context */
4545
fieldMap?: {
46-
[fieldId: string]: { columnName: string };
46+
[fieldId: string]: {
47+
columnName: string;
48+
fieldType?: string;
49+
dbGenerated?: boolean;
50+
expandedExpression?: string;
51+
};
4752
};
4853
/** Whether this is a new table creation (affects SQLite generated columns) */
4954
isNewTable?: boolean;
@@ -104,8 +109,12 @@ export class PostgresDatabaseColumnVisitor implements IFieldVisitor<void> {
104109
fieldMap: this.context.fieldMap,
105110
};
106111

112+
// Use expanded expression if available, otherwise use original expression
113+
const fieldInfo = this.context.fieldMap[field.id];
114+
const expressionToConvert = fieldInfo?.expandedExpression || field.options.expression;
115+
107116
const conversionResult = this.context.dbProvider.convertFormula(
108-
field.options.expression,
117+
expressionToConvert,
109118
conversionContext
110119
);
111120

apps/nestjs-backend/src/features/field/database-column-visitor.sqlite.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,12 @@ export interface IDatabaseColumnContext {
4343
dbProvider?: IDbProvider;
4444
/** Field map for formula conversion context */
4545
fieldMap?: {
46-
[fieldId: string]: { columnName: string };
46+
[fieldId: string]: {
47+
columnName: string;
48+
fieldType?: string;
49+
dbGenerated?: boolean;
50+
expandedExpression?: string;
51+
};
4752
};
4853
/** Whether this is a new table creation (affects SQLite generated columns) */
4954
isNewTable?: boolean;
@@ -104,8 +109,12 @@ export class SqliteDatabaseColumnVisitor implements IFieldVisitor<void> {
104109
fieldMap: this.context.fieldMap,
105110
};
106111

112+
// Use expanded expression if available, otherwise use original expression
113+
const fieldInfo = this.context.fieldMap[field.id];
114+
const expressionToConvert = fieldInfo?.expandedExpression || field.options.expression;
115+
107116
const conversionResult = this.context.dbProvider.convertFormula(
108-
field.options.expression,
117+
expressionToConvert,
109118
conversionContext
110119
);
111120

apps/nestjs-backend/src/features/field/database-column-visitor.test.ts

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { FormulaFieldCore, FieldType, CellValueType, DbFieldType } from '@teable/core';
1+
import {
2+
FormulaFieldCore,
3+
FieldType,
4+
CellValueType,
5+
DbFieldType,
6+
getGeneratedColumnName,
7+
} from '@teable/core';
28
import { plainToInstance } from 'class-transformer';
39
import type { Knex } from 'knex';
410
import type { Mock } from 'vitest';
@@ -124,7 +130,7 @@ describe('Database Column Visitor', () => {
124130

125131
expect(mockDoubleFn).toHaveBeenCalledWith('test_field');
126132
expect(mockSpecificTypeFn).toHaveBeenCalledWith(
127-
'test_field___generated',
133+
getGeneratedColumnName('test_field'),
128134
'DOUBLE PRECISION GENERATED ALWAYS AS (COALESCE("field1", 0) + COALESCE("field2", 0)) STORED'
129135
);
130136
expect(mockDoubleFn).toHaveBeenCalledTimes(1);
@@ -162,6 +168,71 @@ describe('Database Column Visitor', () => {
162168
expect(mockSpecificTypeFn).not.toHaveBeenCalled();
163169
expect(mockDoubleFn).toHaveBeenCalledTimes(1);
164170
});
171+
172+
it('should use expanded expression when available', () => {
173+
const formulaField = plainToInstance(FormulaFieldCore, {
174+
id: 'fld123',
175+
name: 'Formula Field',
176+
type: FieldType.Formula,
177+
dbFieldType: DbFieldType.Real,
178+
dbFieldName: 'test_field',
179+
cellValueType: CellValueType.Number,
180+
options: {
181+
expression: '{fld456} * 2', // Original expression
182+
dbGenerated: true,
183+
},
184+
});
185+
186+
const mockDbProvider = {
187+
convertFormula: vi.fn().mockReturnValue({
188+
sql: '("field1" + 10) * 2',
189+
dependencies: ['field1'],
190+
}),
191+
};
192+
193+
const fieldMapWithExpansion = {
194+
fld123: {
195+
columnName: 'test_field',
196+
fieldType: 'formula',
197+
dbGenerated: true,
198+
expandedExpression: '({fld456} + 10) * 2', // Expanded expression
199+
},
200+
fld456: {
201+
columnName: 'field1',
202+
fieldType: 'formula',
203+
dbGenerated: true,
204+
},
205+
field1: {
206+
columnName: 'field1',
207+
fieldType: 'number',
208+
dbGenerated: false,
209+
},
210+
};
211+
212+
const expansionContext: IDatabaseColumnContext = {
213+
table: mockTable,
214+
fieldId: 'fld123',
215+
dbFieldName: 'test_field',
216+
dbProvider: mockDbProvider as any,
217+
fieldMap: fieldMapWithExpansion,
218+
};
219+
220+
const visitor = new PostgresDatabaseColumnVisitor(expansionContext);
221+
formulaField.accept(visitor);
222+
223+
// Should call convertFormula with expanded expression, not original
224+
expect(mockDbProvider.convertFormula).toHaveBeenCalledWith(
225+
'({fld456} + 10) * 2', // Expanded expression
226+
expect.objectContaining({
227+
fieldMap: fieldMapWithExpansion,
228+
})
229+
);
230+
231+
expect(mockSpecificTypeFn).toHaveBeenCalledWith(
232+
getGeneratedColumnName('test_field'),
233+
'DOUBLE PRECISION GENERATED ALWAYS AS (("field1" + 10) * 2) STORED'
234+
);
235+
});
165236
});
166237

167238
describe('SqliteDatabaseColumnVisitor', () => {
@@ -215,7 +286,7 @@ describe('Database Column Visitor', () => {
215286

216287
expect(mockDoubleFn).toHaveBeenCalledWith('test_field');
217288
expect(mockSpecificTypeFn).toHaveBeenCalledWith(
218-
'test_field___generated',
289+
getGeneratedColumnName('test_field'),
219290
'REAL GENERATED ALWAYS AS (COALESCE(`field1`, 0) + COALESCE(`field2`, 0)) VIRTUAL'
220291
);
221292
expect(mockDoubleFn).toHaveBeenCalledTimes(1);
@@ -246,7 +317,7 @@ describe('Database Column Visitor', () => {
246317

247318
expect(mockDoubleFn).toHaveBeenCalledWith('test_field');
248319
expect(mockSpecificTypeFn).toHaveBeenCalledWith(
249-
'test_field___generated',
320+
getGeneratedColumnName('test_field'),
250321
'REAL GENERATED ALWAYS AS (COALESCE(`field1`, 0) + COALESCE(`field2`, 0)) STORED'
251322
);
252323
expect(mockDoubleFn).toHaveBeenCalledTimes(1);

apps/nestjs-backend/src/features/field/field.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import { Module } from '@nestjs/common';
22
import { DbProvider } from '../../db-provider/db.provider';
33
import { CalculationModule } from '../calculation/calculation.module';
44
import { FieldService } from './field.service';
5+
import { FormulaExpansionService } from './formula-expansion.service';
56

67
@Module({
78
imports: [CalculationModule],
8-
providers: [FieldService, DbProvider],
9+
providers: [FieldService, DbProvider, FormulaExpansionService],
910
exports: [FieldService],
1011
})
1112
export class FieldModule {}

0 commit comments

Comments
 (0)