• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

teableio / teable / 18796737877

25 Oct 2025 02:18AM UTC coverage: 75.183% (+0.03%) from 75.153%
18796737877

Pull #2042

github

web-flow
Merge fb797848f into 48774d441
Pull Request #2042: fix/conditional rollup bugs

10263 of 11059 branches covered (92.8%)

322 of 416 new or added lines in 9 files covered. (77.4%)

8 existing lines in 5 files now uncovered.

50961 of 67783 relevant lines covered (75.18%)

4504.02 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.5
/apps/nestjs-backend/src/features/record/query-builder/providers/pg-record-query-dialect.ts
1
import { DriverClient, FieldType, CellValueType, DbFieldType } from '@teable/core';
2✔
2
import type { INumberFormatting, ICurrencyFormatting, Relationship, FieldCore } from '@teable/core';
3
import type { Knex } from 'knex';
4
import type { IRecordQueryDialectProvider } from '../record-query-dialect.interface';
5

6
export class PgRecordQueryDialect implements IRecordQueryDialectProvider {
2✔
7
  readonly driver = DriverClient.Pg as const;
3,650✔
8

9
  constructor(private readonly knex: Knex) {}
3,650✔
10

11
  toText(expr: string): string {
3,650✔
12
    return `(${expr})::TEXT`;
24✔
13
  }
24✔
14

15
  formatNumber(expr: string, formatting: INumberFormatting): string {
3,650✔
16
    const { type, precision } = formatting;
456✔
17
    switch (type) {
456✔
18
      case 'decimal':
456✔
19
        return `ROUND(CAST(${expr} AS NUMERIC), ${precision ?? 0})::TEXT`;
438✔
20
      case 'percent':
456✔
21
        return `ROUND(CAST(${expr} * 100 AS NUMERIC), ${precision ?? 0})::TEXT || '%'`;
9✔
22
      case 'currency': {
456✔
23
        const symbol = (formatting as ICurrencyFormatting).symbol || '$';
9✔
24
        if (typeof precision === 'number') {
9✔
25
          return `'${symbol}' || ROUND(CAST(${expr} AS NUMERIC), ${precision})::TEXT`;
9✔
26
        }
9✔
27
        return `'${symbol}' || (${expr})::TEXT`;
×
28
      }
×
29
      default:
456✔
30
        return `(${expr})::TEXT`;
×
31
    }
456✔
32
  }
456✔
33

34
  formatNumberArray(expr: string, formatting: INumberFormatting): string {
3,650✔
35
    const elem = `(elem #>> '{}')::numeric`;
19✔
36
    const formatted = this.formatNumber(elem, formatting).replace(
19✔
37
      /\(elem #>> '\{\}'\)::numeric/,
19✔
38
      elem
19✔
39
    );
40
    return `(
19✔
41
        SELECT string_agg(${formatted}, ', ' ORDER BY ord)
19✔
42
        FROM jsonb_array_elements(COALESCE((${expr})::jsonb, '[]'::jsonb)) WITH ORDINALITY AS t(elem, ord)
19✔
43
      )`;
19✔
44
  }
19✔
45

46
  formatStringArray(expr: string): string {
3,650✔
47
    return `(
29✔
48
        SELECT string_agg(
49
          CASE
50
            WHEN jsonb_typeof(elem) = 'string' THEN elem #>> '{}'
51
            WHEN jsonb_typeof(elem) = 'object' THEN elem->>'title'
52
            ELSE elem::text
53
          END,
54
          ', '
55
          ORDER BY ord
56
        )
57
        FROM jsonb_array_elements(COALESCE((${expr})::jsonb, '[]'::jsonb)) WITH ORDINALITY AS t(elem, ord)
29✔
58
      )`;
29✔
59
  }
29✔
60

61
  formatRating(expr: string): string {
3,650✔
62
    return `CASE WHEN (${expr} = ROUND(${expr})) THEN ROUND(${expr})::TEXT ELSE (${expr})::TEXT END`;
20✔
63
  }
20✔
64

65
  coerceToNumericForCompare(expr: string): string {
3,650✔
66
    // Same safe numeric coercion used for arithmetic
5✔
67
    return `NULLIF(REGEXP_REPLACE((${expr})::text, '[^0-9.+-]', '', 'g'), '')::numeric`;
5✔
68
  }
5✔
69

70
  linkHasAny(selectionSql: string): string {
3,650✔
71
    return `(${selectionSql} IS NOT NULL AND ${selectionSql}::text != 'null' AND ${selectionSql}::text != '[]')`;
12✔
72
  }
12✔
73

74
  linkExtractTitles(selectionSql: string, isMultiple: boolean): string {
3,650✔
75
    if (isMultiple) {
95✔
76
      return `(SELECT json_agg(value->>'title') FROM jsonb_array_elements(${selectionSql}::jsonb) AS value)::jsonb`;
32✔
77
    }
32✔
78
    return `(${selectionSql}->>'title')`;
63✔
79
  }
63✔
80

81
  jsonTitleFromExpr(selectionSql: string): string {
3,650✔
82
    return `(${selectionSql}->>'title')`;
×
83
  }
×
84

85
  selectUserNameById(idRef: string): string {
3,650✔
86
    return `(SELECT u.name FROM users u WHERE u.id = ${idRef})`;
2✔
87
  }
2✔
88

89
  buildUserJsonObjectById(idRef: string): string {
3,650✔
90
    return `(
33✔
91
        SELECT jsonb_build_object('id', u.id, 'title', u.name, 'email', u.email)
92
        FROM users u
93
        WHERE u.id = ${idRef}
33✔
94
      )`;
33✔
95
  }
33✔
96

97
  flattenLookupCteValue(cteName: string, fieldId: string, isMultiple: boolean): string | null {
3,650✔
98
    if (!isMultiple) return null;
3,023✔
99
    return `(
2,041✔
100
            WITH RECURSIVE f(e) AS (
101
              SELECT "${cteName}"."lookup_${fieldId}"::jsonb
2,041✔
102
              UNION ALL
103
              SELECT jsonb_array_elements(f.e)
104
              FROM f
105
              WHERE jsonb_typeof(f.e) = 'array'
106
            )
107
            SELECT jsonb_agg(e) FILTER (WHERE jsonb_typeof(e) <> 'array') FROM f
108
          )`;
2,041✔
109
  }
2,041✔
110

111
  jsonAggregateNonNull(expression: string, orderByClause?: string): string {
3,650✔
112
    const order = orderByClause ? ` ORDER BY ${orderByClause}` : '';
99✔
113
    // Use jsonb_agg so downstream consumers (persisted link/lookup columns) expecting jsonb
99✔
114
    // do not hit implicit cast issues during UPDATE ... FROM assignments.
99✔
115
    return `jsonb_agg(${expression}${order}) FILTER (WHERE ${expression} IS NOT NULL)`;
99✔
116
  }
99✔
117

118
  stringAggregate(expression: string, delimiter: string, orderByClause?: string): string {
3,650✔
119
    const order = orderByClause ? ` ORDER BY ${orderByClause}` : '';
×
120
    return `STRING_AGG(${expression}::text, ${this.knex.raw('?', [delimiter]).toQuery()}${order})`;
×
121
  }
×
122

123
  jsonArrayLength(expr: string): string {
3,650✔
124
    return `jsonb_array_length(${expr}::jsonb)`;
×
125
  }
×
126

127
  nullJson(): string {
3,650✔
128
    return 'NULL::json';
×
129
  }
×
130

131
  typedNullFor(dbFieldType: DbFieldType): string {
3,650✔
132
    switch (dbFieldType) {
756✔
133
      case DbFieldType.Json:
756✔
134
        return 'NULL::jsonb';
231✔
135
      case DbFieldType.Integer:
756✔
136
        return 'NULL::integer';
×
137
      case DbFieldType.Real:
756✔
138
        return 'NULL::double precision';
224✔
139
      case DbFieldType.DateTime:
756✔
140
        return 'NULL::timestamptz';
30✔
141
      case DbFieldType.Boolean:
756✔
142
        return 'NULL::boolean';
33✔
143
      case DbFieldType.Blob:
756✔
144
        return 'NULL::bytea';
×
145
      case DbFieldType.Text:
756✔
146
      default:
756✔
147
        return 'NULL::text';
238✔
148
    }
756✔
149
  }
756✔
150

151
  private castAgg(sql: string): string {
3,650✔
152
    // normalize to double precision for numeric rollups
1,213✔
153
    return `CAST(${sql} AS DOUBLE PRECISION)`;
1,213✔
154
  }
1,213✔
155

156
  // eslint-disable-next-line sonarjs/cognitive-complexity
3,650✔
157
  rollupAggregate(
3,650✔
158
    fn: string,
1,764✔
159
    fieldExpression: string,
1,764✔
160
    opts: {
1,764✔
161
      targetField?: FieldCore;
162
      orderByField?: string;
163
      rowPresenceExpr?: string;
164
      flattenNestedArray?: boolean;
165
    }
1,764✔
166
  ): string {
1,764✔
167
    const { targetField, orderByField, rowPresenceExpr, flattenNestedArray } = opts;
1,764✔
168
    switch (fn) {
1,764✔
169
      case 'sum':
1,764✔
170
        // Prefer numeric targets: number field or formula resolving to number
354✔
171
        if (
354✔
172
          targetField?.type === FieldType.Number ||
354✔
173
          // Some computed/lookup/rollup/ formula fields expose numeric cellValueType
44✔
174
          // Use optional chaining to avoid issues on core field types without this prop
44✔
175
          (targetField as unknown as { cellValueType?: CellValueType })?.cellValueType ===
44✔
176
            CellValueType.Number
44✔
177
        ) {
354✔
178
          return this.castAgg(`COALESCE(SUM(${fieldExpression}), 0)`);
351✔
179
        }
351✔
180
        // Non-numeric target: avoid SUM() casting errors
3✔
181
        return this.castAgg('SUM(0)');
3✔
182
      case 'average':
1,764✔
183
        if (
108✔
184
          targetField?.type === FieldType.Number ||
108✔
185
          (targetField as unknown as { cellValueType?: CellValueType })?.cellValueType ===
×
186
            CellValueType.Number
×
187
        ) {
108✔
188
          return this.castAgg(`COALESCE(AVG(${fieldExpression}), 0)`);
108✔
189
        }
108✔
190
        return this.castAgg('AVG(0)');
×
191
      case 'count':
1,764✔
192
        return this.castAgg(`COALESCE(COUNT(${fieldExpression}), 0)`);
184✔
193
      case 'countall': {
1,764✔
194
        if (targetField?.type === FieldType.MultipleSelect) {
540✔
195
          return this.castAgg(
40✔
196
            `COALESCE(SUM(CASE WHEN ${fieldExpression} IS NOT NULL THEN jsonb_array_length(${fieldExpression}::jsonb) ELSE 0 END), 0)`
40✔
197
          );
198
        }
40✔
199
        const base = rowPresenceExpr ?? fieldExpression;
540✔
200
        return this.castAgg(`COALESCE(COUNT(${base}), 0)`);
540✔
201
      }
540✔
202
      case 'counta':
1,764✔
203
        return this.castAgg(`COALESCE(COUNT(${fieldExpression}), 0)`);
6✔
204
      case 'max': {
1,764✔
205
        const isDateFieldType =
15✔
206
          targetField?.type === FieldType.Date ||
15✔
207
          targetField?.type === FieldType.CreatedTime ||
15✔
208
          targetField?.type === FieldType.LastModifiedTime;
15✔
209
        const isDateTimeTarget =
15✔
210
          isDateFieldType ||
15✔
211
          targetField?.cellValueType === CellValueType.DateTime ||
15✔
212
          targetField?.dbFieldType === DbFieldType.DateTime;
15✔
213
        const aggregate = `MAX(${fieldExpression})`;
15✔
214
        return isDateTimeTarget ? aggregate : this.castAgg(aggregate);
15✔
215
      }
15✔
216
      case 'min': {
1,764✔
217
        const isDateFieldType =
7✔
218
          targetField?.type === FieldType.Date ||
7✔
219
          targetField?.type === FieldType.CreatedTime ||
7✔
220
          targetField?.type === FieldType.LastModifiedTime;
7✔
221
        const isDateTimeTarget =
7✔
222
          isDateFieldType ||
7✔
223
          targetField?.cellValueType === CellValueType.DateTime ||
7✔
224
          targetField?.dbFieldType === DbFieldType.DateTime;
7✔
225
        const aggregate = `MIN(${fieldExpression})`;
7✔
226
        return isDateTimeTarget ? aggregate : this.castAgg(aggregate);
7✔
227
      }
7✔
228
      case 'and':
1,764✔
229
        return `BOOL_AND(${fieldExpression}::boolean)`;
5✔
230
      case 'or':
1,764✔
231
        return `BOOL_OR(${fieldExpression}::boolean)`;
5✔
232
      case 'xor':
1,764✔
233
        return `(COUNT(CASE WHEN ${fieldExpression}::boolean THEN 1 END) % 2 = 1)`;
5✔
234
      case 'array_join':
1,764✔
235
      case 'concatenate':
1,764✔
236
        return orderByField
142✔
237
          ? `STRING_AGG(${fieldExpression}::text, ', ' ORDER BY ${orderByField})`
123✔
238
          : `STRING_AGG(${fieldExpression}::text, ', ')`;
142✔
239
      case 'array_unique':
1,764✔
240
        return `json_agg(DISTINCT ${fieldExpression})`;
63✔
241
      case 'array_compact': {
1,764✔
242
        const buildAggregate = (expr: string) =>
330✔
243
          orderByField
330✔
244
            ? `jsonb_agg(${expr} ORDER BY ${orderByField}) FILTER (WHERE (${expr}) IS NOT NULL AND (${expr})::text <> '')`
86✔
245
            : `jsonb_agg(${expr}) FILTER (WHERE (${expr}) IS NOT NULL AND (${expr})::text <> '')`;
330✔
246
        const baseAggregate = buildAggregate(fieldExpression);
330✔
247
        if (flattenNestedArray) {
330✔
248
          return `(WITH RECURSIVE flattened(val) AS (
5✔
249
              SELECT COALESCE(${baseAggregate}, '[]'::jsonb)
5✔
250
              UNION ALL
251
              SELECT elem
252
              FROM flattened
253
              CROSS JOIN LATERAL jsonb_array_elements(flattened.val) AS elem
254
              WHERE jsonb_typeof(flattened.val) = 'array'
255
            )
256
            SELECT jsonb_agg(val) FILTER (
257
              WHERE jsonb_typeof(val) <> 'array'
258
                AND jsonb_typeof(val) <> 'null'
259
                AND val <> '""'::jsonb
260
            ) FROM flattened)`;
5✔
261
        }
5✔
262
        return baseAggregate;
325✔
263
      }
325✔
264
      default:
1,764✔
265
        throw new Error(`Unsupported rollup function: ${fn}`);
×
266
    }
1,764✔
267
  }
1,764✔
268

269
  singleValueRollupAggregate(
3,650✔
270
    fn: string,
1,015✔
271
    fieldExpression: string,
1,015✔
272
    options: { rollupField: FieldCore; targetField: FieldCore }
1,015✔
273
  ): string {
1,015✔
274
    const requiresJsonArray = options.rollupField.dbFieldType === DbFieldType.Json;
1,015✔
275
    switch (fn) {
1,015✔
276
      case 'sum':
1,015✔
277
      case 'average':
1,015✔
278
        // For single-value relationships, SUM reduces to the value itself.
837✔
279
        // Coalesce to 0 and cast to double precision for numeric stability.
837✔
280
        // If the expression is non-numeric, upstream rollup setup should avoid SUM on such targets.
837✔
281
        return `COALESCE(CAST(${fieldExpression} AS DOUBLE PRECISION), 0)`;
837✔
282
      case 'max':
1,015✔
283
      case 'min':
1,015✔
284
      case 'array_join':
1,015✔
285
      case 'concatenate':
1,015✔
286
        return `${fieldExpression}`;
31✔
287
      case 'count':
1,015✔
288
      case 'countall':
1,015✔
289
      case 'counta':
1,015✔
290
        return `CASE WHEN ${fieldExpression} IS NULL THEN 0 ELSE 1 END`;
147✔
291
      case 'and':
1,015✔
292
      case 'or':
1,015✔
293
      case 'xor':
1,015✔
294
        return `(COALESCE((${fieldExpression})::boolean, false))`;
×
295
      case 'array_unique':
1,015✔
296
      case 'array_compact':
1,015✔
NEW
297
        if (!requiresJsonArray) {
×
NEW
298
          return `${fieldExpression}`;
×
NEW
299
        }
×
NEW
300
        return `(CASE WHEN ${fieldExpression} IS NULL THEN '[]'::jsonb ELSE jsonb_build_array(${fieldExpression}) END)`;
×
301
      default:
1,015✔
302
        return `${fieldExpression}`;
×
303
    }
1,015✔
304
  }
1,015✔
305

306
  buildLinkJsonObject(
3,650✔
307
    recordIdRef: string,
11,392✔
308
    formattedSelectionExpression: string,
11,392✔
309
    _rawSelectionExpression: string
11,392✔
310
  ): string {
11,392✔
311
    return `jsonb_strip_nulls(jsonb_build_object('id', ${recordIdRef}, 'title', ${formattedSelectionExpression}))::jsonb`;
11,392✔
312
  }
11,392✔
313

314
  applyLinkCteOrdering(
3,650✔
315
    _qb: Knex.QueryBuilder,
×
316
    _opts: {
×
317
      relationship: Relationship;
318
      usesJunctionTable: boolean;
319
      hasOrderColumn: boolean;
320
      junctionAlias: string;
321
      foreignAlias: string;
322
      selfKeyName: string;
323
    }
×
324
  ): void {
×
325
    // Postgres needs no extra ordering hacks at CTE level for json_agg
×
326
  }
×
327

328
  buildDeterministicLookupAggregate(): string | null {
3,650✔
329
    // PG returns null to signal not needed; caller should use json_agg with ORDER BY
×
330
    return null;
×
331
  }
×
332
}
3,650✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc