Skip to content

Commit 39fcce9

Browse files
committed
fix: implement boolean field normalization in SQL conversion and add e2e tests for logical formulas
1 parent 0bbdcc4 commit 39fcce9

File tree

3 files changed

+150
-2
lines changed

3 files changed

+150
-2
lines changed

‎apps/nestjs-backend/src/db-provider/select-query/postgres/select-query.postgres.spec.ts‎

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
/* eslint-disable sonarjs/no-duplicate-string */
2-
import { TableDomain } from '@teable/core';
2+
import { CellValueType, DbFieldType, FieldType, TableDomain } from '@teable/core';
3+
import type { IFieldVo } from '@teable/core';
4+
import knex from 'knex';
35
import { beforeEach, describe, expect, it } from 'vitest';
46

7+
import { createFieldInstanceByVo } from '../../../features/field/model/factory';
58
import type { IFieldSelectName } from '../../../features/record/query-builder/field-select.type';
69
import type { ISelectFormulaConversionContext } from '../../../features/record/query-builder/sql-conversion.visitor';
10+
import { PostgresProvider } from '../../postgres.provider';
711
import { SelectQueryPostgres } from './select-query.postgres';
812

913
describe('SelectQueryPostgres unit-aware date helpers', () => {
@@ -267,3 +271,69 @@ describe('SelectQueryPostgres unit-aware date helpers', () => {
267271
});
268272
});
269273
});
274+
275+
describe('Select formula boolean normalization', () => {
276+
const knexClient = knex({ client: 'pg' });
277+
const provider = new PostgresProvider(knexClient);
278+
279+
const booleanFieldVo: IFieldVo = {
280+
id: 'fldBoolean001',
281+
name: 'Boolean Flag',
282+
type: FieldType.Checkbox,
283+
options: {},
284+
dbFieldName: 'bool_col',
285+
dbFieldType: DbFieldType.Boolean,
286+
cellValueType: CellValueType.Boolean,
287+
isLookup: false,
288+
isComputed: false,
289+
isMultipleCellValue: false,
290+
};
291+
292+
const textFieldVo: IFieldVo = {
293+
id: 'fldText001',
294+
name: 'Text Field',
295+
type: FieldType.SingleLineText,
296+
options: {},
297+
dbFieldName: 'text_col',
298+
dbFieldType: DbFieldType.Text,
299+
cellValueType: CellValueType.String,
300+
isLookup: false,
301+
isComputed: false,
302+
isMultipleCellValue: false,
303+
};
304+
305+
const booleanField = createFieldInstanceByVo(booleanFieldVo);
306+
const textField = createFieldInstanceByVo(textFieldVo);
307+
308+
const table = new TableDomain({
309+
id: 'tblBoolean',
310+
name: 'Boolean Table',
311+
dbTableName: 'boolean_table',
312+
lastModifiedTime: '1970-01-01T00:00:00.000Z',
313+
fields: [booleanField, textField],
314+
});
315+
316+
const buildContext = (): ISelectFormulaConversionContext => ({
317+
table,
318+
tableAlias: 'main',
319+
selectionMap: new Map<string, IFieldSelectName>([
320+
[booleanField.id, '"main"."bool_col"'],
321+
[textField.id, '"main"."text_col"'],
322+
]),
323+
timeZone: 'UTC',
324+
preferRawFieldReferences: true,
325+
});
326+
327+
it('casts boolean field references before PostgreSQL COALESCE', () => {
328+
const sql = provider.convertFormulaToSelectQuery('AND({fldBoolean001})', buildContext());
329+
330+
expect(sql).toContain('(("main"."bool_col"))::boolean');
331+
expect(sql).toContain('COALESCE');
332+
});
333+
334+
it('does not cast non-boolean field references', () => {
335+
const sql = provider.convertFormulaToSelectQuery('AND({fldText001})', buildContext());
336+
337+
expect(sql).not.toContain('::boolean');
338+
});
339+
});

‎apps/nestjs-backend/src/features/record/query-builder/sql-conversion.visitor.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -818,7 +818,7 @@ abstract class BaseSqlConversionVisitor<
818818
if (driver === DriverClient.Sqlite) {
819819
return `(COALESCE((${valueSql}), 0) != 0)`;
820820
}
821-
return `(COALESCE((${valueSql}), FALSE))`;
821+
return `(COALESCE(${this.normalizeBooleanFieldReference(valueSql, exprCtx) ?? valueSql}, FALSE))`;
822822
case 'number': {
823823
if (driver === DriverClient.Sqlite) {
824824
const numericExpr = this.safeCastToNumeric(valueSql);
@@ -848,6 +848,30 @@ abstract class BaseSqlConversionVisitor<
848848
}
849849
}
850850

851+
/**
852+
* Coerce direct field references carrying boolean semantics into a proper boolean scalar.
853+
* This keeps the SQL maintainable by leveraging schema metadata rather than runtime pg_typeof checks.
854+
*/
855+
private normalizeBooleanFieldReference(valueSql: string, exprCtx: ExprContext): string | null {
856+
if (!(exprCtx instanceof FieldReferenceCurlyContext)) {
857+
return null;
858+
}
859+
860+
const fieldId = exprCtx.text.slice(1, -1);
861+
const fieldInfo = this.context.table?.getField(fieldId);
862+
if (!fieldInfo) {
863+
return null;
864+
}
865+
866+
const isBooleanField =
867+
fieldInfo.dbFieldType === DbFieldType.Boolean || fieldInfo.cellValueType === 'boolean';
868+
if (!isBooleanField) {
869+
return null;
870+
}
871+
872+
return `((${valueSql}))::boolean`;
873+
}
874+
851875
private isBlankLikeExpression(ctx: ExprContext): boolean {
852876
if (ctx instanceof StringLiteralContext) {
853877
const raw = ctx.text;

‎apps/nestjs-backend/test/formula.e2e-spec.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1323,6 +1323,60 @@ describe('OpenAPI formula (e2e)', () => {
13231323
expect(values.not).toBe(true);
13241324
});
13251325

1326+
it('should evaluate logical formulas referencing boolean checkbox fields', async () => {
1327+
const checkboxField = await createField(table1Id, {
1328+
name: 'logical-checkbox',
1329+
type: FieldType.Checkbox,
1330+
options: {},
1331+
});
1332+
1333+
const booleanFormulaField = await createField(table1Id, {
1334+
name: 'logical-checkbox-formula',
1335+
type: FieldType.Formula,
1336+
options: {
1337+
expression: `AND({${checkboxField.id}}, {${numberFieldRo.id}} > 0)`,
1338+
},
1339+
});
1340+
1341+
const { records } = await createRecords(table1Id, {
1342+
fieldKeyType: FieldKeyType.Name,
1343+
records: [
1344+
{
1345+
fields: {
1346+
[checkboxField.name]: true,
1347+
[numberFieldRo.name]: 5,
1348+
[textFieldRo.name]: 'flagged',
1349+
},
1350+
},
1351+
],
1352+
});
1353+
1354+
const recordId = records[0].id;
1355+
const initialValue = records[0].fields[booleanFormulaField.name];
1356+
expect(typeof initialValue).toBe('boolean');
1357+
expect(initialValue).toBe(true);
1358+
1359+
const uncheckedRecord = await updateRecord(table1Id, recordId, {
1360+
fieldKeyType: FieldKeyType.Name,
1361+
record: {
1362+
fields: {
1363+
[checkboxField.name]: null,
1364+
},
1365+
},
1366+
});
1367+
expect(uncheckedRecord.fields[booleanFormulaField.name]).toBe(false);
1368+
1369+
const recheckedRecord = await updateRecord(table1Id, recordId, {
1370+
fieldKeyType: FieldKeyType.Name,
1371+
record: {
1372+
fields: {
1373+
[checkboxField.name]: true,
1374+
},
1375+
},
1376+
});
1377+
expect(recheckedRecord.fields[booleanFormulaField.name]).toBe(true);
1378+
});
1379+
13261380
it('should treat numeric IF fallbacks with blank branches as nulls', async () => {
13271381
const numericCondition = await createField(table1Id, {
13281382
name: 'numeric-condition',

0 commit comments

Comments
 (0)