Skip to content

Commit af2390f

Browse files
committed
feat: add context management for generated columns in formula queries and update related tests
1 parent b4b6016 commit af2390f

File tree

9 files changed

+162
-3
lines changed

9 files changed

+162
-3
lines changed

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
1-
import type { IFormulaQueryInterface } from './formula-query.interface';
1+
import type { IFormulaQueryInterface, IFormulaConversionContext } from './formula-query.interface';
22

33
/**
44
* Abstract base class for formula query implementations
55
* Provides common functionality and default implementations
66
*/
77
export abstract class FormulaQueryAbstract implements IFormulaQueryInterface {
8+
/** Current conversion context */
9+
protected context?: IFormulaConversionContext;
10+
11+
/** Set the conversion context */
12+
setContext(context: IFormulaConversionContext): void {
13+
this.context = context;
14+
}
15+
16+
/** Check if we're in a generated column context */
17+
protected get isGeneratedColumnContext(): boolean {
18+
return this.context?.isGeneratedColumn ?? false;
19+
}
820
// Numeric Functions
921
abstract sum(params: string[]): string;
1022
abstract average(params: string[]): string;

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import type { CellValueType } from '@teable/core';
2-
31
/**
42
* Interface for database-specific formula function implementations
53
* Each database provider (PostgreSQL, SQLite) should implement this interface
64
* to provide SQL translations for Teable formula functions
75
*/
86
export interface IFormulaQueryInterface {
7+
// Context management
8+
setContext(context: IFormulaConversionContext): void;
99
// Numeric Functions
1010
sum(params: string[]): string;
1111
average(params: string[]): string;
@@ -159,6 +159,8 @@ export interface IFormulaConversionContext {
159159
};
160160
};
161161
timeZone?: string;
162+
/** Whether this conversion is for a generated column (affects immutable function handling) */
163+
isGeneratedColumn?: boolean;
162164
}
163165

164166
/**

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,10 +175,20 @@ export class FormulaQueryPostgres extends FormulaQueryAbstract {
175175

176176
// DateTime Functions
177177
now(): string {
178+
// For generated columns, use the current timestamp at field creation time
179+
if (this.isGeneratedColumnContext) {
180+
const currentTimestamp = new Date().toISOString().replace('T', ' ').replace('Z', '');
181+
return `'${currentTimestamp}'::timestamp`;
182+
}
178183
return 'NOW()';
179184
}
180185

181186
today(): string {
187+
// For generated columns, use the current date at field creation time
188+
if (this.isGeneratedColumnContext) {
189+
const currentDate = new Date().toISOString().split('T')[0];
190+
return `'${currentDate}'::date`;
191+
}
182192
return 'CURRENT_DATE';
183193
}
184194

@@ -225,6 +235,11 @@ export class FormulaQueryPostgres extends FormulaQueryAbstract {
225235
}
226236

227237
fromNow(date: string): string {
238+
// For generated columns, use the current timestamp at field creation time
239+
if (this.isGeneratedColumnContext) {
240+
const currentTimestamp = new Date().toISOString().replace('T', ' ').replace('Z', '');
241+
return `EXTRACT(EPOCH FROM '${currentTimestamp}'::timestamp - ${date}::timestamp)`;
242+
}
228243
return `EXTRACT(EPOCH FROM NOW() - ${date}::timestamp)`;
229244
}
230245

@@ -279,6 +294,11 @@ export class FormulaQueryPostgres extends FormulaQueryAbstract {
279294
}
280295

281296
toNow(date: string): string {
297+
// For generated columns, use the current timestamp at field creation time
298+
if (this.isGeneratedColumnContext) {
299+
const currentTimestamp = new Date().toISOString().replace('T', ' ').replace('Z', '');
300+
return `EXTRACT(EPOCH FROM ${date}::timestamp - '${currentTimestamp}'::timestamp)`;
301+
}
282302
return `EXTRACT(EPOCH FROM ${date}::timestamp - NOW())`;
283303
}
284304

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,10 +175,20 @@ export class FormulaQuerySqlite extends FormulaQueryAbstract {
175175

176176
// DateTime Functions
177177
now(): string {
178+
// For generated columns, use the current timestamp at field creation time
179+
if (this.isGeneratedColumnContext) {
180+
const currentTimestamp = new Date().toISOString().replace('T', ' ').replace('Z', '');
181+
return `'${currentTimestamp}'`;
182+
}
178183
return "DATETIME('now')";
179184
}
180185

181186
today(): string {
187+
// For generated columns, use the current date at field creation time
188+
if (this.isGeneratedColumnContext) {
189+
const currentDate = new Date().toISOString().split('T')[0];
190+
return `'${currentDate}'`;
191+
}
182192
return "DATE('now')";
183193
}
184194

@@ -246,6 +256,11 @@ export class FormulaQuerySqlite extends FormulaQueryAbstract {
246256
}
247257

248258
fromNow(date: string): string {
259+
// For generated columns, use the current timestamp at field creation time
260+
if (this.isGeneratedColumnContext) {
261+
const currentTimestamp = new Date().toISOString().replace('T', ' ').replace('Z', '');
262+
return `(JULIANDAY('${currentTimestamp}') - JULIANDAY(${date})) * 24 * 60 * 60`;
263+
}
249264
return `(JULIANDAY('now') - JULIANDAY(${date})) * 24 * 60 * 60`;
250265
}
251266

@@ -299,6 +314,11 @@ export class FormulaQuerySqlite extends FormulaQueryAbstract {
299314
}
300315

301316
toNow(date: string): string {
317+
// For generated columns, use the current timestamp at field creation time
318+
if (this.isGeneratedColumnContext) {
319+
const currentTimestamp = new Date().toISOString().replace('T', ' ').replace('Z', '');
320+
return `(JULIANDAY(${date}) - JULIANDAY('${currentTimestamp}')) * 24 * 60 * 60`;
321+
}
302322
return `(JULIANDAY(${date}) - JULIANDAY('now')) * 24 * 60 * 60`;
303323
}
304324

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,9 @@ ORDER BY
595595
convertFormula(expression: string, context: IFormulaConversionContext): IFormulaConversionResult {
596596
try {
597597
const formulaQuery = this.formulaQuery();
598+
// Set the context on the formula query instance
599+
formulaQuery.setContext(context);
600+
598601
const visitor = new SqlConversionVisitor(formulaQuery, context);
599602

600603
const sql = parseFormulaToSQL(expression, visitor);

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,9 @@ ORDER BY
518518
convertFormula(expression: string, context: IFormulaConversionContext): IFormulaConversionResult {
519519
try {
520520
const formulaQuery = this.formulaQuery();
521+
// Set the context on the formula query instance
522+
formulaQuery.setContext(context);
523+
521524
const visitor = new SqlConversionVisitor(formulaQuery, context);
522525

523526
const sql = parseFormulaToSQL(expression, visitor);

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export class PostgresDatabaseColumnVisitor implements IFieldVisitor<void> {
107107

108108
const conversionContext: IFormulaConversionContext = {
109109
fieldMap: this.context.fieldMap,
110+
isGeneratedColumn: true, // Mark this as a generated column context
110111
};
111112

112113
// Use expanded expression if available, otherwise use original expression

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export class SqliteDatabaseColumnVisitor implements IFieldVisitor<void> {
107107

108108
const conversionContext: IFormulaConversionContext = {
109109
fieldMap: this.context.fieldMap,
110+
isGeneratedColumn: true, // Mark this as a generated column context
110111
};
111112

112113
// Use expanded expression if available, otherwise use original expression

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

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { Knex } from 'knex';
1010
import type { Mock } from 'vitest';
1111
import { describe, it, expect, beforeEach, vi } from 'vitest';
1212
import type { IDbProvider } from '../../db-provider/db.provider.interface';
13+
import type { IFormulaConversionContext } from '../../db-provider/formula-query/formula-query.interface';
1314
import {
1415
PostgresDatabaseColumnVisitor,
1516
type IDatabaseColumnContext,
@@ -356,5 +357,101 @@ describe('Database Column Visitor', () => {
356357
expect(mockTextFn).toHaveBeenCalledTimes(1);
357358
expect(mockSpecificTypeFn).toHaveBeenCalledTimes(1);
358359
});
360+
361+
it('should pass isGeneratedColumn context for PostgreSQL generated columns', () => {
362+
// Mock the convertFormula to capture the context and return a realistic SQL with current timestamp
363+
let capturedContext: IFormulaConversionContext | undefined;
364+
(mockDbProvider.convertFormula as Mock).mockImplementation(
365+
(expression: string, context: IFormulaConversionContext) => {
366+
capturedContext = context;
367+
// Simulate what would happen with YEAR(NOW()) in generated column context
368+
const currentTimestamp = new Date().toISOString().replace('T', ' ').replace('Z', '');
369+
return {
370+
sql: `EXTRACT(YEAR FROM '${currentTimestamp}'::timestamp)`,
371+
dependencies: [],
372+
};
373+
}
374+
);
375+
376+
const formulaField = plainToInstance(FormulaFieldCore, {
377+
id: 'fld123',
378+
name: 'Formula Field',
379+
type: FieldType.Formula,
380+
dbFieldType: DbFieldType.Integer,
381+
cellValueType: CellValueType.Number,
382+
dbFieldName: 'test_field',
383+
options: {
384+
expression: 'YEAR(NOW())',
385+
dbGenerated: true,
386+
},
387+
});
388+
389+
const visitor = new PostgresDatabaseColumnVisitor(context);
390+
formulaField.accept(visitor);
391+
392+
expect(capturedContext?.isGeneratedColumn).toBe(true);
393+
expect(mockIntegerFn).toHaveBeenCalledWith('test_field');
394+
// The exact timestamp will vary, so we just check the pattern
395+
expect(mockSpecificTypeFn).toHaveBeenCalledWith(
396+
getGeneratedColumnName('test_field'),
397+
expect.stringMatching(
398+
/INTEGER GENERATED ALWAYS AS \(EXTRACT\(YEAR FROM '\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}'::timestamp\)\) STORED/
399+
)
400+
);
401+
});
402+
});
403+
404+
describe('SqliteDatabaseColumnVisitor - Non-deterministic function replacement', () => {
405+
let sqliteContext: IDatabaseColumnContext;
406+
407+
beforeEach(() => {
408+
mockKnex.client.config.client = 'sqlite3';
409+
sqliteContext = {
410+
...context,
411+
dbProvider: mockSqliteDbProvider,
412+
};
413+
});
414+
415+
it('should pass isGeneratedColumn context for SQLite generated columns', () => {
416+
// Mock the convertFormula to capture the context and return a realistic SQL with current timestamp
417+
let capturedContext: IFormulaConversionContext | undefined;
418+
(mockSqliteDbProvider.convertFormula as Mock).mockImplementation(
419+
(_expression: string, context: IFormulaConversionContext) => {
420+
capturedContext = context;
421+
// Simulate what would happen with YEAR(NOW()) in generated column context
422+
const currentTimestamp = new Date().toISOString().replace('T', ' ').replace('Z', '');
423+
return {
424+
sql: `CAST(STRFTIME('%Y', '${currentTimestamp}') AS INTEGER)`,
425+
dependencies: [],
426+
};
427+
}
428+
);
429+
430+
const formulaField = plainToInstance(FormulaFieldCore, {
431+
id: 'fld123',
432+
name: 'Formula Field',
433+
type: FieldType.Formula,
434+
dbFieldType: DbFieldType.Integer,
435+
cellValueType: CellValueType.Number,
436+
dbFieldName: 'test_field',
437+
options: {
438+
expression: 'YEAR(NOW())',
439+
dbGenerated: true,
440+
},
441+
});
442+
443+
const visitor = new SqliteDatabaseColumnVisitor(sqliteContext);
444+
formulaField.accept(visitor);
445+
446+
expect(capturedContext?.isGeneratedColumn).toBe(true);
447+
expect(mockIntegerFn).toHaveBeenCalledWith('test_field');
448+
// The exact timestamp will vary, so we just check the pattern
449+
expect(mockSpecificTypeFn).toHaveBeenCalledWith(
450+
getGeneratedColumnName('test_field'),
451+
expect.stringMatching(
452+
/INTEGER GENERATED ALWAYS AS \(CAST\(STRFTIME\('%Y', '\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}'\) AS INTEGER\)\) VIRTUAL/
453+
)
454+
);
455+
});
359456
});
360457
});

0 commit comments

Comments
 (0)