Skip to content

Commit 2c7cd9b

Browse files
authored
Merge pull request #2081 from teableio/fix/multiple-value-rollup
feat: enhance SQL logging and improve numeric expression handling in query dialect
2 parents dc21063 + ccbf372 commit 2c7cd9b

File tree

9 files changed

+313
-44
lines changed

9 files changed

+313
-44
lines changed

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

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -137,18 +137,17 @@ export class GeneratedColumnQueryPostgres extends GeneratedColumnQueryAbstract {
137137
}
138138

139139
const wrapped = `(${value})`;
140+
const jsonbValue = `to_jsonb${wrapped}`;
140141
const coerced = `(CASE
141142
WHEN ${wrapped} IS NULL THEN NULL
142-
WHEN pg_typeof(${wrapped}) = ANY(ARRAY['jsonb'::regtype, 'json'::regtype]) THEN (
143-
CASE jsonb_typeof((${wrapped})::jsonb)
144-
WHEN 'string' THEN (${wrapped})::jsonb #>> '{}'
145-
WHEN 'number' THEN (${wrapped})::jsonb #>> '{}'
146-
WHEN 'boolean' THEN (${wrapped})::jsonb #>> '{}'
143+
ELSE
144+
CASE jsonb_typeof(${jsonbValue})
145+
WHEN 'string' THEN ${jsonbValue} #>> '{}'
146+
WHEN 'number' THEN ${jsonbValue} #>> '{}'
147+
WHEN 'boolean' THEN ${jsonbValue} #>> '{}'
147148
WHEN 'null' THEN NULL
148-
ELSE (${wrapped})::jsonb::text
149+
ELSE ${jsonbValue}::text
149150
END
150-
)
151-
ELSE ${wrapped}::text
152151
END)`;
153152
return this.ensureTextCollation(coerced);
154153
}

apps/nestjs-backend/src/features/record/computed/services/computed-dependency-collector.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,7 @@ export class ComputedDependencyCollectorService {
388388

389389
const unionQuery = this.knex.queryBuilder().union(selects);
390390
const finalQuery = this.knex.select('id').from(unionQuery.as('u')).distinct('id').toQuery();
391+
this.logger.debug(`Linked Record IDs SQL: ${finalQuery}`);
391392
const rows = await this.prismaService.txClient().$queryRawUnsafe<{ id: string }[]>(finalQuery);
392393
return rows.map((r) => r.id).filter(Boolean);
393394
}

apps/nestjs-backend/src/features/record/computed/services/record-computed-update.service.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Injectable, Logger } from '@nestjs/common';
2+
import { Prisma } from '@prisma/client';
23
import { FieldType } from '@teable/core';
34
import { PrismaService } from '@teable/db-main-prisma';
45
import type { Knex } from 'knex';
@@ -102,12 +103,17 @@ export class RecordComputedUpdateService {
102103
const columnNames = this.getUpdatableColumns(fields);
103104
const returningNames = this.getReturningColumns(fields);
104105
if (!columnNames.length) {
106+
const selectSql = qb.toQuery();
105107
// No updatable columns (e.g., all are generated formulas). Return current values via SELECT.
106-
return await this.prismaService
107-
.txClient()
108-
.$queryRawUnsafe<
109-
Array<{ __id: string; __version: number } & Record<string, unknown>>
110-
>(qb.toQuery());
108+
try {
109+
return await this.prismaService
110+
.txClient()
111+
.$queryRawUnsafe<
112+
Array<{ __id: string; __version: number } & Record<string, unknown>>
113+
>(selectSql);
114+
} catch (error) {
115+
this.handleRawQueryError(error, selectSql);
116+
}
111117
}
112118

113119
const returningWithAutoNumber = Array.from(
@@ -122,8 +128,29 @@ export class RecordComputedUpdateService {
122128
returningDbFieldNames: returningWithAutoNumber,
123129
});
124130
this.logger.debug('updateFromSelect SQL:', sql);
125-
return await this.prismaService
126-
.txClient()
127-
.$queryRawUnsafe<Array<{ __id: string; __version: number } & Record<string, unknown>>>(sql);
131+
try {
132+
return await this.prismaService
133+
.txClient()
134+
.$queryRawUnsafe<Array<{ __id: string; __version: number } & Record<string, unknown>>>(sql);
135+
} catch (error) {
136+
this.handleRawQueryError(error, sql);
137+
}
138+
}
139+
140+
private handleRawQueryError(error: unknown, sql: string): never {
141+
if (error instanceof Prisma.PrismaClientKnownRequestError) {
142+
error.message = `${error.message}\nSQL: ${sql}`;
143+
Object.assign(error, { sql });
144+
this.logger.error(
145+
`updateFromSelect known request error. SQL: ${sql}`,
146+
error.stack ?? undefined
147+
);
148+
throw error;
149+
}
150+
this.logger.error(
151+
`updateFromSelect unexpected query error. SQL: ${sql}`,
152+
(error as Error)?.stack
153+
);
154+
throw error;
128155
}
129156
}

apps/nestjs-backend/src/features/record/open-api/record-open-api.service.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,9 @@ export class RecordOpenApiService {
128128
tableId,
129129
[recordId],
130130
undefined,
131-
updateRecordRo.fieldKeyType || FieldKeyType.Name
131+
updateRecordRo.fieldKeyType || FieldKeyType.Name,
132+
undefined,
133+
true
132134
);
133135

134136
if (snapshots.length !== 1) {

apps/nestjs-backend/src/features/record/query-builder/formula-support-generated-column-validator.ts

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable sonarjs/no-identical-functions */
12
import type {
23
TableDomain,
34
IFunctionCallInfo,
@@ -59,6 +60,14 @@ export class FormulaSupportGeneratedColumnValidator {
5960
return false;
6061
}
6162

63+
if (this.hasLogicalNonBooleanArgs(tree)) {
64+
return false;
65+
}
66+
67+
if (this.containsLogicalFunctions(tree)) {
68+
return false;
69+
}
70+
6271
// Extract all function calls from the AST
6372
const collector = new FunctionCallCollectorVisitor();
6473
const functionCalls = collector.visit(tree);
@@ -137,10 +146,16 @@ export class FormulaSupportGeneratedColumnValidator {
137146

138147
// If it's a formula field, recursively check its dependencies
139148
if (field.type === FieldType.Formula) {
149+
const formulaField = field as FormulaFieldCore;
150+
151+
if (!formulaField.getIsPersistedAsGeneratedColumn()) {
152+
return false;
153+
}
154+
140155
visitedFields.add(fieldId);
141156

142157
try {
143-
const expression = (field as FormulaFieldCore).getExpression();
158+
const expression = formulaField.getExpression();
144159
if (expression) {
145160
const tree = parseFormula(expression);
146161
return this.validateFieldReferences(tree, visitedFields);
@@ -554,12 +569,13 @@ export class FormulaSupportGeneratedColumnValidator {
554569

555570
// eslint-disable-next-line sonarjs/no-identical-functions
556571
visitChildren(node: RuleNode): boolean {
557-
const n = node.childCount;
558-
for (let i = 0; i < n; i++) {
559-
const child = node.getChild(i);
572+
let index = 0;
573+
while (index < node.childCount) {
574+
const child = node.getChild(index);
560575
if (child && child.accept(this)) {
561576
return true;
562577
}
578+
index++;
563579
}
564580
return false;
565581
}
@@ -582,6 +598,89 @@ export class FormulaSupportGeneratedColumnValidator {
582598
return tree.accept(new DatetimeConcatDetector()) ?? false;
583599
}
584600

601+
private hasLogicalNonBooleanArgs(tree: ExprContext): boolean {
602+
// eslint-disable-next-line @typescript-eslint/no-this-alias
603+
const self = this;
604+
class LogicalArgumentDetector extends AbstractParseTreeVisitor<boolean> {
605+
protected defaultResult(): boolean {
606+
return false;
607+
}
608+
609+
visitChildren(node: RuleNode): boolean {
610+
const n = node.childCount;
611+
for (let i = 0; i < n; i++) {
612+
const child = node.getChild(i);
613+
if (child && child.accept(this)) {
614+
return true;
615+
}
616+
}
617+
return false;
618+
}
619+
620+
visitFunctionCall(ctx: FunctionCallContext): boolean {
621+
const rawName = ctx.func_name().text.toUpperCase();
622+
const fnName = normalizeFunctionNameAlias(rawName) as FunctionName;
623+
const isLogical =
624+
fnName === FunctionName.And ||
625+
fnName === FunctionName.Or ||
626+
fnName === FunctionName.Not ||
627+
fnName === FunctionName.Xor;
628+
629+
if (isLogical) {
630+
const exprs = ctx.expr();
631+
for (const exprCtx of exprs) {
632+
const argType = self.inferBasicType(exprCtx);
633+
if (argType === 'string' || argType === 'number' || argType === 'datetime') {
634+
return true;
635+
}
636+
}
637+
}
638+
639+
return this.visitChildren(ctx);
640+
}
641+
}
642+
643+
return tree.accept(new LogicalArgumentDetector()) ?? false;
644+
}
645+
646+
private containsLogicalFunctions(tree: ExprContext): boolean {
647+
class LogicalFunctionDetector extends AbstractParseTreeVisitor<boolean> {
648+
protected defaultResult(): boolean {
649+
return false;
650+
}
651+
652+
visitChildren(node: RuleNode): boolean {
653+
let index = 0;
654+
while (index < node.childCount) {
655+
const child = node.getChild(index);
656+
if (child && child.accept(this)) {
657+
return true;
658+
}
659+
index++;
660+
}
661+
return false;
662+
}
663+
664+
visitFunctionCall(ctx: FunctionCallContext): boolean {
665+
const rawName = ctx.func_name().text.toUpperCase();
666+
const fnName = normalizeFunctionNameAlias(rawName) as FunctionName;
667+
const isLogical =
668+
fnName === FunctionName.And ||
669+
fnName === FunctionName.Or ||
670+
fnName === FunctionName.Not ||
671+
fnName === FunctionName.Xor;
672+
673+
if (isLogical) {
674+
return true;
675+
}
676+
677+
return this.visitChildren(ctx);
678+
}
679+
}
680+
681+
return tree.accept(new LogicalFunctionDetector()) ?? false;
682+
}
683+
585684
// eslint-disable-next-line sonarjs/cognitive-complexity
586685
private inferBasicType(
587686
ctx: ExprContext

apps/nestjs-backend/src/features/record/query-builder/providers/pg-record-query-dialect.ts

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,37 @@ export class PgRecordQueryDialect implements IRecordQueryDialectProvider {
246246
}
247247
}
248248

249+
private sanitizeNumericTextExpression(expression: string): string {
250+
const textExpr = `((${expression})::text COLLATE "C")`;
251+
const sanitized = `REGEXP_REPLACE(${textExpr}, '[^0-9.+-]', '', 'g')`;
252+
return `NULLIF(${sanitized}, '' COLLATE "C")::double precision`;
253+
}
254+
255+
private buildJsonNumericSumExpression(fieldExpression: string): string {
256+
const expr = `(${fieldExpression})`;
257+
const scalarValue = this.sanitizeNumericTextExpression(expr);
258+
const arraySum = `(SELECT SUM(${this.sanitizeNumericTextExpression('elem.value')})
259+
FROM jsonb_array_elements_text(${expr}::jsonb) AS elem(value))`;
260+
return `(CASE
261+
WHEN ${expr} IS NULL THEN 0
262+
WHEN jsonb_typeof(${expr}::jsonb) = 'array' THEN COALESCE(${arraySum}, 0)
263+
ELSE COALESCE(${scalarValue}, 0)
264+
END)`;
265+
}
266+
267+
private buildJsonNumericCountExpression(fieldExpression: string): string {
268+
const expr = `(${fieldExpression})`;
269+
const scalarValue = this.sanitizeNumericTextExpression(expr);
270+
const scalarCount = `(CASE WHEN ${scalarValue} IS NULL THEN 0 ELSE 1 END)`;
271+
const elementCount = `(SELECT SUM(CASE WHEN ${this.sanitizeNumericTextExpression('elem.value')} IS NULL THEN 0 ELSE 1 END)
272+
FROM jsonb_array_elements_text(${expr}::jsonb) AS elem(value))`;
273+
return `(CASE
274+
WHEN ${expr} IS NULL THEN 0
275+
WHEN jsonb_typeof(${expr}::jsonb) = 'array' THEN COALESCE(${elementCount}, 0)
276+
ELSE ${scalarCount}
277+
END)`;
278+
}
279+
249280
private castAgg(sql: string): string {
250281
// normalize to double precision for numeric rollups
251282
return `CAST(${sql} AS DOUBLE PRECISION)`;
@@ -263,26 +294,34 @@ export class PgRecordQueryDialect implements IRecordQueryDialectProvider {
263294
}
264295
): string {
265296
const { targetField, orderByField, rowPresenceExpr, flattenNestedArray } = opts;
297+
const isNumericTarget =
298+
targetField?.type === FieldType.Number ||
299+
(targetField as unknown as { cellValueType?: CellValueType })?.cellValueType ===
300+
CellValueType.Number;
301+
266302
switch (fn) {
267303
case 'sum':
268304
// Prefer numeric targets: number field or formula resolving to number
269-
if (
270-
targetField?.type === FieldType.Number ||
271-
// Some computed/lookup/rollup/ formula fields expose numeric cellValueType
272-
// Use optional chaining to avoid issues on core field types without this prop
273-
(targetField as unknown as { cellValueType?: CellValueType })?.cellValueType ===
274-
CellValueType.Number
275-
) {
305+
if (isNumericTarget) {
306+
if (targetField?.isMultipleCellValue) {
307+
const numericExpr = this.buildJsonNumericSumExpression(fieldExpression);
308+
return this.castAgg(`COALESCE(SUM(${numericExpr}), 0)`);
309+
}
276310
return this.castAgg(`COALESCE(SUM(${fieldExpression}), 0)`);
277311
}
278312
// Non-numeric target: avoid SUM() casting errors
279313
return this.castAgg('SUM(0)');
280314
case 'average':
281-
if (
282-
targetField?.type === FieldType.Number ||
283-
(targetField as unknown as { cellValueType?: CellValueType })?.cellValueType ===
284-
CellValueType.Number
285-
) {
315+
if (isNumericTarget) {
316+
if (targetField?.isMultipleCellValue) {
317+
const sumExpr = this.buildJsonNumericSumExpression(fieldExpression);
318+
const countExpr = this.buildJsonNumericCountExpression(fieldExpression);
319+
const sumAgg = `COALESCE(SUM(${sumExpr}), 0)`;
320+
const countAgg = `COALESCE(SUM(${countExpr}), 0)`;
321+
return this.castAgg(
322+
`CASE WHEN ${countAgg} = 0 THEN 0 ELSE ${sumAgg} / ${countAgg} END`
323+
);
324+
}
286325
return this.castAgg(`COALESCE(AVG(${fieldExpression}), 0)`);
287326
}
288327
return this.castAgg('AVG(0)');

apps/nestjs-backend/src/features/record/record-modify/record-create.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export class RecordCreateService {
9595
this.recordService.convertProjection(projection),
9696
fieldKeyType,
9797
CellFormat.Json,
98-
false
98+
true
9999
);
100100
return { records: snapshots.map((s) => s.data) };
101101
}

0 commit comments

Comments
 (0)