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

teableio / teable / 19560844213

21 Nov 2025 05:19AM UTC coverage: 74.056% (-0.002%) from 74.058%
19560844213

push

github

web-flow
feat: implement timezone-aware datetime slicing in formulas (#2159)

11537 of 12485 branches covered (92.41%)

202 of 270 new or added lines in 7 files covered. (74.81%)

11 existing lines in 3 files now uncovered.

55535 of 74991 relevant lines covered (74.06%)

4306.0 hits per line

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

82.84
/apps/nestjs-backend/src/features/record/query-builder/sql-conversion.visitor.ts
1
/* eslint-disable regexp/no-dupe-characters-character-class */
2✔
2
/* eslint-disable sonarjs/no-duplicated-branches */
2✔
3
/* eslint-disable @typescript-eslint/naming-convention */
2✔
4
/* eslint-disable sonarjs/no-collapsible-if */
2✔
5
/* eslint-disable sonarjs/no-identical-functions */
2✔
6
/* eslint-disable @typescript-eslint/no-non-null-assertion */
2✔
7
/* eslint-disable @typescript-eslint/no-explicit-any */
2✔
8

9
import {
10
  StringLiteralContext,
11
  IntegerLiteralContext,
12
  LeftWhitespaceOrCommentsContext,
13
  RightWhitespaceOrCommentsContext,
14
  CircularReferenceError,
15
  FunctionCallContext,
16
  FunctionName,
17
  FieldType,
18
  CellValueType,
19
  DriverClient,
20
  AbstractParseTreeVisitor,
21
  BinaryOpContext,
22
  BooleanLiteralContext,
23
  BracketsContext,
24
  DecimalLiteralContext,
25
  FieldReferenceCurlyContext,
26
  isLinkField,
27
  parseFormula,
28
  isFieldHasExpression,
29
  isFormulaField,
30
  isLinkLookupOptions,
31
  normalizeFunctionNameAlias,
32
  DbFieldType,
33
  DateFormattingPreset,
34
  extractFieldReferenceId,
35
  getFieldReferenceTokenText,
36
  FUNCTIONS,
37
  Relationship,
38
  TimeFormatting,
39
} from '@teable/core';
40
import type {
41
  FormulaVisitor,
42
  ExprContext,
43
  TableDomain,
44
  FieldCore,
45
  AutoNumberFieldCore,
46
  CreatedTimeFieldCore,
47
  LastModifiedTimeFieldCore,
48
  FormulaFieldCore,
49
  IFieldWithExpression,
50
  IFormulaParamMetadata,
51
  IFormulaParamFieldMetadata,
52
  FormulaParamType,
53
  IDatetimeFormatting,
54
} from '@teable/core';
55
import type { ITeableToDbFunctionConverter } from '@teable/core/src/formula/function-convertor.interface';
56
import type { RootContext, UnaryOpContext } from '@teable/core/src/formula/parser/Formula';
57
import type { Knex } from 'knex';
58
import { match } from 'ts-pattern';
59
import type { IFieldSelectName } from './field-select.type';
60
import { PgRecordQueryDialect } from './providers/pg-record-query-dialect';
61
import { SqliteRecordQueryDialect } from './providers/sqlite-record-query-dialect';
62
import type { IRecordSelectionMap } from './record-query-builder.interface';
63
import type { IRecordQueryDialectProvider } from './record-query-dialect.interface';
64

65
function unescapeString(str: string): string {
1,921✔
66
  return str.replace(/\\(.)/g, (_, char) => {
1,921✔
67
    return match(char)
×
68
      .with('n', () => '\n')
×
69
      .with('t', () => '\t')
×
70
      .with('r', () => '\r')
×
71
      .with('\\', () => '\\')
×
72
      .with("'", () => "'")
×
73
      .with('"', () => '"')
×
74
      .otherwise((c) => c);
×
75
  });
×
76
}
1,921✔
77

78
const STRING_FUNCTIONS = new Set<FunctionName>([
2✔
79
  FunctionName.Concatenate,
2✔
80
  FunctionName.Left,
2✔
81
  FunctionName.Right,
2✔
82
  FunctionName.Mid,
2✔
83
  FunctionName.Upper,
2✔
84
  FunctionName.Lower,
2✔
85
  FunctionName.Trim,
2✔
86
  FunctionName.Substitute,
2✔
87
  FunctionName.Replace,
2✔
88
  FunctionName.T,
2✔
89
  FunctionName.Datestr,
2✔
90
  FunctionName.Timestr,
2✔
91
  FunctionName.ArrayJoin,
2✔
92
]);
2✔
93

94
const NUMBER_FUNCTIONS = new Set<FunctionName>([
2✔
95
  FunctionName.Sum,
2✔
96
  FunctionName.Average,
2✔
97
  FunctionName.Max,
2✔
98
  FunctionName.Min,
2✔
99
  FunctionName.Round,
2✔
100
  FunctionName.RoundUp,
2✔
101
  FunctionName.RoundDown,
2✔
102
  FunctionName.Ceiling,
2✔
103
  FunctionName.Floor,
2✔
104
  FunctionName.Abs,
2✔
105
  FunctionName.Sqrt,
2✔
106
  FunctionName.Power,
2✔
107
  FunctionName.Exp,
2✔
108
  FunctionName.Log,
2✔
109
  FunctionName.Mod,
2✔
110
  FunctionName.Value,
2✔
111
  FunctionName.Find,
2✔
112
  FunctionName.Search,
2✔
113
  FunctionName.Len,
2✔
114
  FunctionName.Count,
2✔
115
  FunctionName.CountA,
2✔
116
  FunctionName.CountAll,
2✔
117
]);
2✔
118

119
const BOOLEAN_FUNCTIONS = new Set<FunctionName>([
2✔
120
  FunctionName.And,
2✔
121
  FunctionName.Or,
2✔
122
  FunctionName.Not,
2✔
123
  FunctionName.Xor,
2✔
124
]);
2✔
125

126
const MULTI_VALUE_AGGREGATED_FUNCTIONS = new Set<FunctionName>([
2✔
127
  FunctionName.DatetimeFormat,
2✔
128
  FunctionName.Value,
2✔
129
  FunctionName.Abs,
2✔
130
  FunctionName.Datestr,
2✔
131
  FunctionName.Timestr,
2✔
132
  FunctionName.Day,
2✔
133
  FunctionName.Month,
2✔
134
  FunctionName.Year,
2✔
135
  FunctionName.Weekday,
2✔
136
  FunctionName.WeekNum,
2✔
137
  FunctionName.Hour,
2✔
138
  FunctionName.Minute,
2✔
139
  FunctionName.Second,
2✔
140
  FunctionName.FromNow,
2✔
141
  FunctionName.ToNow,
2✔
142
  FunctionName.Round,
2✔
143
  FunctionName.RoundUp,
2✔
144
  FunctionName.RoundDown,
2✔
145
  FunctionName.Floor,
2✔
146
  FunctionName.Ceiling,
2✔
147
  FunctionName.Int,
2✔
148
]);
2✔
149

150
const MULTI_VALUE_FIELD_TYPES = new Set<FieldType>([
2✔
151
  FieldType.Link,
2✔
152
  FieldType.Attachment,
2✔
153
  FieldType.MultipleSelect,
2✔
154
  FieldType.User,
2✔
155
  FieldType.CreatedBy,
2✔
156
  FieldType.LastModifiedBy,
2✔
157
]);
2✔
158

159
const STRING_FIELD_TYPES = new Set<FieldType>([
2✔
160
  FieldType.SingleLineText,
2✔
161
  FieldType.LongText,
2✔
162
  FieldType.SingleSelect,
2✔
163
  FieldType.MultipleSelect,
2✔
164
  FieldType.User,
2✔
165
  FieldType.CreatedBy,
2✔
166
  FieldType.LastModifiedBy,
2✔
167
  FieldType.Attachment,
2✔
168
  FieldType.Link,
2✔
169
  FieldType.Button,
2✔
170
]);
2✔
171

172
const DATETIME_FIELD_TYPES = new Set<FieldType>([
2✔
173
  FieldType.Date,
2✔
174
  FieldType.CreatedTime,
2✔
175
  FieldType.LastModifiedTime,
2✔
176
]);
2✔
177

178
const NUMBER_FIELD_TYPES = new Set<FieldType>([
2✔
179
  FieldType.Number,
2✔
180
  FieldType.Rating,
2✔
181
  FieldType.AutoNumber,
2✔
182
  FieldType.Rollup,
2✔
183
]);
2✔
184

185
/**
186
 * Context information for formula conversion
187
 */
188
export interface IFormulaConversionContext {
189
  table: TableDomain;
190
  /** Whether this conversion is for a generated column (affects immutable function handling) */
191
  isGeneratedColumn?: boolean;
192
  driverClient?: DriverClient;
193
  expansionCache?: Map<string, string>;
194
  /** Optional timezone to interpret date/time literals and fields in SELECT context */
195
  timeZone?: string;
196
}
197

198
/**
199
 * Extended context for select query formula conversion with CTE support
200
 */
201
export interface ISelectFormulaConversionContext extends IFormulaConversionContext {
202
  selectionMap: IRecordSelectionMap;
203
  /** Table alias to use for field references */
204
  tableAlias?: string;
205
  /** CTE map: linkFieldId -> cteName */
206
  fieldCteMap?: ReadonlyMap<string, string>;
207
  /** Link field IDs whose CTEs have already been emitted (safe for reference) */
208
  readyLinkFieldIds?: ReadonlySet<string>;
209
  /** Current link field id whose CTE is being generated (used to avoid self references) */
210
  currentLinkFieldId?: string;
211
  /** When true, prefer raw field references (no title formatting) to preserve native types */
212
  preferRawFieldReferences?: boolean;
213
  /** Target DB field type for the enclosing formula selection (used for type-sensitive raw projection) */
214
  targetDbFieldType?: DbFieldType;
215
}
216

217
/**
218
 * Result of formula conversion
219
 */
220
export interface IFormulaConversionResult {
221
  sql: string;
222
  dependencies: string[]; // field IDs that this formula depends on
223
}
224

225
/**
226
 * Interface for database-specific generated column query implementations
227
 * Each database provider (PostgreSQL, SQLite) should implement this interface
228
 * to provide SQL translations for Teable formula functions that will be used
229
 * in database generated columns. This interface ensures formula expressions
230
 * are converted to immutable SQL expressions suitable for generated columns.
231
 */
232
export interface IGeneratedColumnQueryInterface
233
  extends ITeableToDbFunctionConverter<string, IFormulaConversionContext> {}
234

235
/**
236
 * Interface for database-specific SELECT query implementations
237
 * Each database provider (PostgreSQL, SQLite) should implement this interface
238
 * to provide SQL translations for Teable formula functions that will be used
239
 * in SELECT statements as computed columns. Unlike generated columns, these
240
 * expressions can use mutable functions and have different optimization strategies.
241
 */
242
export interface ISelectQueryInterface
243
  extends ITeableToDbFunctionConverter<string, IFormulaConversionContext> {}
244

245
/**
246
 * Interface for validating whether Teable formula functions convert to generated column are supported
247
 * by a specific database provider. Each method returns a boolean indicating
248
 * whether the corresponding function can be converted to a valid database expression.
249
 */
250
export interface IGeneratedColumnQuerySupportValidator
251
  extends ITeableToDbFunctionConverter<boolean, IFormulaConversionContext> {}
252

253
/**
2✔
254
 * Get should expand field reference
255
 *
256
 * @param field
257
 * @returns boolean
258
 */
2✔
259
function shouldExpandFieldReference(
3,714✔
260
  field: FieldCore
3,714✔
261
): field is
262
  | FormulaFieldCore
263
  | AutoNumberFieldCore
264
  | CreatedTimeFieldCore
265
  | LastModifiedTimeFieldCore {
266
  if (isFormulaField(field) && field.isLookup) {
3,714✔
267
    return false;
1✔
268
  }
1✔
269
  return isFieldHasExpression(field);
3,713✔
270
}
3,713✔
271

272
/**
2✔
273
 * Abstract base visitor that contains common functionality for SQL conversion
274
 */
2✔
275
abstract class BaseSqlConversionVisitor<
3,228✔
276
    TFormulaQuery extends ITeableToDbFunctionConverter<string, IFormulaConversionContext>,
277
  >
278
  extends AbstractParseTreeVisitor<string>
3,228✔
279
  implements FormulaVisitor<IFieldSelectName>
280
{
281
  protected expansionStack: Set<string> = new Set();
3,228✔
282

283
  protected defaultResult(): string {
3,228✔
284
    throw new Error('Method not implemented.');
×
285
  }
×
286

287
  protected getQuestionMarkExpression(): string {
3,228✔
288
    if (this.context.driverClient === DriverClient.Sqlite) {
3✔
289
      return 'CHAR(63)';
×
290
    }
×
291
    return 'CHR(63)';
3✔
292
  }
3✔
293

294
  constructor(
3,228✔
295
    protected readonly knex: Knex,
3,228✔
296
    protected formulaQuery: TFormulaQuery,
3,228✔
297
    protected context: IFormulaConversionContext,
3,228✔
298
    protected dialect?: IRecordQueryDialectProvider
3,228✔
299
  ) {
3,228✔
300
    super();
3,228✔
301
    // Initialize a dialect provider for use in driver-specific pieces when callers don't inject one
3,228✔
302
    if (!this.dialect) {
3,228✔
303
      const d = this.context.driverClient;
3,228✔
304
      if (d === DriverClient.Pg) this.dialect = new PgRecordQueryDialect(this.knex);
3,228!
305
      else this.dialect = new SqliteRecordQueryDialect(this.knex);
×
306
    }
3,228✔
307
  }
3,228✔
308

309
  visitRoot(ctx: RootContext): string {
3,228✔
310
    return ctx.expr().accept(this);
3,350✔
311
  }
3,350✔
312

313
  visitStringLiteral(ctx: StringLiteralContext): string {
3,228✔
314
    const quotedString = ctx.text;
1,921✔
315
    const rawString = quotedString.slice(1, -1);
1,921✔
316
    const unescapedString = unescapeString(rawString);
1,921✔
317

318
    if (!unescapedString.includes('?')) {
1,921✔
319
      return this.formulaQuery.stringLiteral(unescapedString);
1,918✔
320
    }
1,918✔
321

322
    const charExpr = this.getQuestionMarkExpression();
3✔
323
    const parts = unescapedString.split('?');
3✔
324
    const segments: string[] = [];
3✔
325

326
    parts.forEach((part, index) => {
3✔
327
      if (part.length) {
6✔
328
        segments.push(this.formulaQuery.stringLiteral(part));
6✔
329
      }
6✔
330
      if (index < parts.length - 1) {
6✔
331
        segments.push(charExpr);
3✔
332
      }
3✔
333
    });
6✔
334

335
    if (segments.length === 0) {
652!
336
      return charExpr;
×
337
    }
✔
338

339
    if (segments.length === 1) {
652!
340
      return segments[0];
×
341
    }
✔
342

343
    return this.formulaQuery.concatenate(segments);
3✔
344
  }
3✔
345

346
  visitIntegerLiteral(ctx: IntegerLiteralContext): string {
3,228✔
347
    const value = parseInt(ctx.text, 10);
2,292✔
348
    return this.formulaQuery.numberLiteral(value);
2,292✔
349
  }
2,292✔
350

351
  visitDecimalLiteral(ctx: DecimalLiteralContext): string {
3,228✔
352
    const value = parseFloat(ctx.text);
166✔
353
    return this.formulaQuery.numberLiteral(value);
166✔
354
  }
166✔
355

356
  visitBooleanLiteral(ctx: BooleanLiteralContext): string {
3,228✔
357
    const value = ctx.text.toUpperCase() === 'TRUE';
2✔
358
    return this.formulaQuery.booleanLiteral(value);
2✔
359
  }
2✔
360

361
  visitLeftWhitespaceOrComments(ctx: LeftWhitespaceOrCommentsContext): string {
3,228✔
362
    return ctx.expr().accept(this);
4,860✔
363
  }
4,860✔
364

365
  visitRightWhitespaceOrComments(ctx: RightWhitespaceOrCommentsContext): string {
3,228✔
366
    return ctx.expr().accept(this);
2,959✔
367
  }
2,959✔
368

369
  visitBrackets(ctx: BracketsContext): string {
3,228✔
370
    const innerExpression = ctx.expr().accept(this);
48✔
371
    return this.formulaQuery.parentheses(innerExpression);
48✔
372
  }
48✔
373

374
  visitUnaryOp(ctx: UnaryOpContext): string {
3,228✔
375
    const operandCtx = ctx.expr();
6✔
376
    const operand = operandCtx.accept(this);
6✔
377
    const operator = ctx.MINUS();
6✔
378
    const metadata = [this.buildParamMetadata(operandCtx)];
6✔
379
    this.formulaQuery.setCallMetadata(metadata);
6✔
380

381
    try {
6✔
382
      if (operator) {
6✔
383
        return this.formulaQuery.unaryMinus(operand);
6✔
384
      }
6!
385
      return operand;
×
386
    } finally {
6✔
387
      this.formulaQuery.setCallMetadata(undefined);
6✔
388
    }
6✔
389
  }
6✔
390

391
  visitFieldReferenceCurly(ctx: FieldReferenceCurlyContext): string {
3,228✔
392
    const normalizedFieldId = extractFieldReferenceId(ctx);
392✔
393
    const rawToken = getFieldReferenceTokenText(ctx);
392✔
394
    const fieldId = normalizedFieldId ?? rawToken?.slice(1, -1).trim() ?? '';
392✔
395

396
    const fieldInfo = this.context.table.getField(fieldId);
392✔
397
    if (!fieldInfo) {
392✔
398
      throw new Error(`Field not found: ${fieldId}`);
×
399
    }
×
400

401
    // Check if this is a formula field that needs recursive expansion
392✔
402
    if (shouldExpandFieldReference(fieldInfo)) {
392✔
403
      return this.expandFormulaField(fieldId, fieldInfo);
28✔
404
    }
28✔
405

406
    // Note: user-related field handling for select queries is implemented
364✔
407
    // in SelectColumnSqlConversionVisitor where selection context exists.
364✔
408

409
    return this.formulaQuery.fieldReference(fieldId, fieldInfo.dbFieldName);
364✔
410
  }
364✔
411

412
  /**
3,228✔
413
   * Recursively expand a formula field reference
414
   * @param fieldId The field ID to expand
415
   * @param fieldInfo The field information
416
   * @returns The expanded SQL expression
417
   */
3,228✔
418
  protected expandFormulaField(fieldId: string, fieldInfo: IFieldWithExpression): string {
3,228✔
419
    // Initialize expansion cache if not present
126✔
420
    if (!this.context.expansionCache) {
126✔
421
      this.context.expansionCache = new Map();
109✔
422
    }
109✔
423

424
    // Check cache first
126✔
425
    if (this.context.expansionCache.has(fieldId)) {
126✔
426
      return this.context.expansionCache.get(fieldId)!;
4✔
427
    }
4✔
428

429
    // Check for circular references
122✔
430
    if (this.expansionStack.has(fieldId)) {
126✔
431
      throw new CircularReferenceError(fieldId, Array.from(this.expansionStack));
×
432
    }
✔
433

434
    const expression = fieldInfo.getExpression();
122✔
435

436
    // If no expression is found, fall back to normal field reference
122✔
437
    if (!expression) {
126✔
438
      return this.formulaQuery.fieldReference(fieldId, fieldInfo.dbFieldName);
×
439
    }
✔
440

441
    // Add to expansion stack to detect circular references
122✔
442
    this.expansionStack.add(fieldId);
122✔
443

444
    try {
122✔
445
      // Recursively expand the expression by parsing and visiting it
122✔
446
      const tree = parseFormula(expression);
122✔
447
      const expandedSql = tree.accept(this);
122✔
448

449
      // Cache the result
122✔
450
      this.context.expansionCache.set(fieldId, expandedSql);
122✔
451

452
      return expandedSql;
122✔
453
    } finally {
122✔
454
      // Remove from expansion stack
122✔
455
      this.expansionStack.delete(fieldId);
122✔
456
    }
122✔
457
  }
126✔
458

459
  visitFunctionCall(ctx: FunctionCallContext): string {
3,228✔
460
    const rawName = ctx.func_name().text.toUpperCase();
1,441✔
461
    const fnName = normalizeFunctionNameAlias(rawName) as FunctionName;
1,441✔
462
    const exprContexts = ctx.expr();
1,441✔
463
    let params = exprContexts.map((exprCtx) => exprCtx.accept(this));
1,441✔
464
    params = this.normalizeFunctionParamsForMultiplicity(fnName, params, exprContexts);
1,441✔
465
    const paramMetadata = exprContexts.map((exprCtx) => this.buildParamMetadata(exprCtx));
1,441✔
466
    this.formulaQuery.setCallMetadata(paramMetadata);
1,441✔
467

468
    const execute = () => {
1,441✔
469
      const multiValueFormat = this.tryBuildMultiValueAggregator(fnName, params, exprContexts);
1,441✔
470
      if (multiValueFormat) {
1,441✔
471
        return multiValueFormat;
41✔
472
      }
41✔
473

474
      return (
1,400✔
475
        match(fnName)
1,400✔
476
          // Numeric Functions
1,400✔
477
          .with(FunctionName.Sum, () => this.formulaQuery.sum(params))
1,400✔
478
          .with(FunctionName.Average, () => this.formulaQuery.average(params))
1,400✔
479
          .with(FunctionName.Max, () => this.formulaQuery.max(params))
1,400✔
480
          .with(FunctionName.Min, () => this.formulaQuery.min(params))
1,400✔
481
          .with(FunctionName.Round, () => this.formulaQuery.round(params[0], params[1]))
1,400✔
482
          .with(FunctionName.RoundUp, () => this.formulaQuery.roundUp(params[0], params[1]))
1,400✔
483
          .with(FunctionName.RoundDown, () => this.formulaQuery.roundDown(params[0], params[1]))
1,400✔
484
          .with(FunctionName.Ceiling, () => this.formulaQuery.ceiling(params[0]))
1,400✔
485
          .with(FunctionName.Floor, () => this.formulaQuery.floor(params[0]))
1,400✔
486
          .with(FunctionName.Even, () => this.formulaQuery.even(params[0]))
1,400✔
487
          .with(FunctionName.Odd, () => this.formulaQuery.odd(params[0]))
1,400✔
488
          .with(FunctionName.Int, () => this.formulaQuery.int(params[0]))
1,400✔
489
          .with(FunctionName.Abs, () => this.formulaQuery.abs(params[0]))
1,400✔
490
          .with(FunctionName.Sqrt, () => this.formulaQuery.sqrt(params[0]))
1,400✔
491
          .with(FunctionName.Power, () => this.formulaQuery.power(params[0], params[1]))
1,400✔
492
          .with(FunctionName.Exp, () => this.formulaQuery.exp(params[0]))
1,400✔
493
          .with(FunctionName.Log, () => this.formulaQuery.log(params[0], params[1]))
1,400✔
494
          .with(FunctionName.Mod, () => this.formulaQuery.mod(params[0], params[1]))
1,400✔
495
          .with(FunctionName.Value, () => this.formulaQuery.value(params[0]))
1,400✔
496

497
          // Text Functions
1,400✔
498
          .with(FunctionName.Concatenate, () => {
1,400✔
499
            const coerced = params.map((param, index) =>
28✔
500
              this.coerceToStringForConcatenation(param, exprContexts[index])
73✔
501
            );
502
            return this.formulaQuery.concatenate(coerced);
28✔
503
          })
28✔
504
          .with(FunctionName.Find, () => this.formulaQuery.find(params[0], params[1], params[2]))
1,400✔
505
          .with(FunctionName.Search, () =>
1,400✔
506
            this.formulaQuery.search(params[0], params[1], params[2])
4✔
507
          )
508
          .with(FunctionName.Mid, () => this.formulaQuery.mid(params[0], params[1], params[2]))
1,400✔
509
          .with(FunctionName.Left, () => {
1,400✔
510
            const textOperand = this.coerceToStringForConcatenation(params[0], exprContexts[0]);
11✔
511
            const sliceLength = this.normalizeTextSliceCount(params[1], exprContexts[1]);
11✔
512
            return this.formulaQuery.left(textOperand, sliceLength);
11✔
513
          })
11✔
514
          .with(FunctionName.Right, () => {
1,400✔
515
            const textOperand = this.coerceToStringForConcatenation(params[0], exprContexts[0]);
7✔
516
            const sliceLength = this.normalizeTextSliceCount(params[1], exprContexts[1]);
7✔
517
            return this.formulaQuery.right(textOperand, sliceLength);
7✔
518
          })
7✔
519
          .with(FunctionName.Replace, () =>
1,400✔
520
            this.formulaQuery.replace(params[0], params[1], params[2], params[3])
2✔
521
          )
522
          .with(FunctionName.RegExpReplace, () =>
1,400✔
523
            this.formulaQuery.regexpReplace(params[0], params[1], params[2])
2✔
524
          )
525
          .with(FunctionName.Substitute, () =>
1,400✔
526
            this.formulaQuery.substitute(params[0], params[1], params[2], params[3])
1✔
527
          )
528
          .with(FunctionName.Lower, () => this.formulaQuery.lower(params[0]))
1,400✔
529
          .with(FunctionName.Upper, () => this.formulaQuery.upper(params[0]))
1,400✔
530
          .with(FunctionName.Rept, () => this.formulaQuery.rept(params[0], params[1]))
1,400✔
531
          .with(FunctionName.Trim, () => this.formulaQuery.trim(params[0]))
1,400✔
532
          .with(FunctionName.Len, () => this.formulaQuery.len(params[0]))
1,400✔
533
          .with(FunctionName.T, () => this.formulaQuery.t(params[0]))
1,400✔
534
          .with(FunctionName.EncodeUrlComponent, () =>
1,400✔
535
            this.formulaQuery.encodeUrlComponent(params[0])
1✔
536
          )
537

538
          // DateTime Functions
1,400✔
539
          .with(FunctionName.Now, () => this.formulaQuery.now())
1,400✔
540
          .with(FunctionName.Today, () => this.formulaQuery.today())
1,400✔
541
          .with(FunctionName.DateAdd, () =>
1,400✔
542
            this.formulaQuery.dateAdd(params[0], params[1], params[2])
36✔
543
          )
544
          .with(FunctionName.Datestr, () => this.formulaQuery.datestr(params[0]))
1,400✔
545
          .with(FunctionName.DatetimeDiff, () => {
1,400✔
546
            const unitExpr = params[2] ?? `'day'`;
32✔
547
            return this.formulaQuery.datetimeDiff(params[0], params[1], unitExpr);
32✔
548
          })
32✔
549
          .with(FunctionName.DatetimeFormat, () =>
1,400✔
550
            this.formulaQuery.datetimeFormat(params[0], params[1])
25✔
551
          )
552
          .with(FunctionName.DatetimeParse, () =>
1,400✔
553
            this.formulaQuery.datetimeParse(params[0], params[1])
141✔
554
          )
555
          .with(FunctionName.Day, () => this.formulaQuery.day(params[0]))
1,400✔
556
          .with(FunctionName.FromNow, () => this.formulaQuery.fromNow(params[0]))
1,400✔
557
          .with(FunctionName.Hour, () => this.formulaQuery.hour(params[0]))
1,400✔
558
          .with(FunctionName.IsAfter, () => this.formulaQuery.isAfter(params[0], params[1]))
1,400✔
559
          .with(FunctionName.IsBefore, () => this.formulaQuery.isBefore(params[0], params[1]))
1,400✔
560
          .with(FunctionName.IsSame, () =>
1,400✔
561
            this.formulaQuery.isSame(params[0], params[1], params[2])
20✔
562
          )
563
          .with(FunctionName.LastModifiedTime, () => this.formulaQuery.lastModifiedTime())
1,400✔
564
          .with(FunctionName.Minute, () => this.formulaQuery.minute(params[0]))
1,400✔
565
          .with(FunctionName.Month, () => this.formulaQuery.month(params[0]))
1,400✔
566
          .with(FunctionName.Second, () => this.formulaQuery.second(params[0]))
1,400✔
567
          .with(FunctionName.Timestr, () => this.formulaQuery.timestr(params[0]))
1,400✔
568
          .with(FunctionName.ToNow, () => this.formulaQuery.toNow(params[0]))
1,400✔
569
          .with(FunctionName.WeekNum, () => this.formulaQuery.weekNum(params[0]))
1,400✔
570
          .with(FunctionName.Weekday, () => this.formulaQuery.weekday(params[0]))
1,400✔
571
          .with(FunctionName.Workday, () => this.formulaQuery.workday(params[0], params[1]))
1,400✔
572
          .with(FunctionName.WorkdayDiff, () => this.formulaQuery.workdayDiff(params[0], params[1]))
1,400✔
573
          .with(FunctionName.Year, () => this.formulaQuery.year(params[0]))
1,400✔
574
          .with(FunctionName.CreatedTime, () => this.formulaQuery.createdTime())
1,400✔
575

576
          // Logical Functions
1,400✔
577
          .with(FunctionName.If, () => {
1,400✔
578
            const [conditionSql, trueSql, falseSql] = params;
596✔
579
            let coercedTrue = trueSql;
596✔
580
            let coercedFalse = falseSql;
596✔
581

582
            const trueExprCtx = exprContexts[1];
596✔
583
            const falseExprCtx = exprContexts[2];
596✔
584
            const trueType = this.inferExpressionType(trueExprCtx);
596✔
585
            const falseType = this.inferExpressionType(falseExprCtx);
596✔
586
            const trueIsBlank = this.isBlankLikeExpression(trueExprCtx) || trueSql.trim() === "''";
596✔
587
            const falseIsBlank =
596✔
588
              this.isBlankLikeExpression(falseExprCtx) || falseSql.trim() === "''";
596✔
589

590
            const shouldNullOutTrueBranch = trueIsBlank && falseType !== 'string';
596✔
591
            const shouldNullOutFalseBranch = falseIsBlank && trueType !== 'string';
596✔
592

593
            if (shouldNullOutTrueBranch) {
596✔
594
              coercedTrue = 'NULL';
7✔
595
            }
7✔
596

597
            if (shouldNullOutFalseBranch) {
596✔
598
              coercedFalse = 'NULL';
6✔
599
            }
6✔
600

601
            if (this.inferExpressionType(ctx) === 'string') {
596✔
602
              coercedTrue = this.coerceCaseBranchToText(coercedTrue);
557✔
603
              coercedFalse = this.coerceCaseBranchToText(coercedFalse);
557✔
604
            }
557✔
605

606
            return this.formulaQuery.if(conditionSql, coercedTrue, coercedFalse);
596✔
607
          })
596✔
608
          .with(FunctionName.And, () => {
1,400✔
609
            const booleanParams = params.map((param, index) =>
75✔
610
              this.normalizeBooleanExpression(param, exprContexts[index])
158✔
611
            );
612
            return this.formulaQuery.and(booleanParams);
75✔
613
          })
75✔
614
          .with(FunctionName.Or, () => {
1,400✔
615
            const booleanParams = params.map((param, index) =>
63✔
616
              this.normalizeBooleanExpression(param, exprContexts[index])
181✔
617
            );
618
            return this.formulaQuery.or(booleanParams);
63✔
619
          })
63✔
620
          .with(FunctionName.Not, () => {
1,400✔
621
            const booleanParam = this.normalizeBooleanExpression(params[0], exprContexts[0]);
24✔
622
            return this.formulaQuery.not(booleanParam);
24✔
623
          })
24✔
624
          .with(FunctionName.Xor, () => {
1,400✔
625
            const booleanParams = params.map((param, index) =>
1✔
626
              this.normalizeBooleanExpression(param, exprContexts[index])
2✔
627
            );
628
            return this.formulaQuery.xor(booleanParams);
1✔
629
          })
1✔
630
          .with(FunctionName.Blank, () => this.formulaQuery.blank())
1,400✔
631
          .with(FunctionName.IsError, () => this.formulaQuery.isError(params[0]))
1,400✔
632
          .with(FunctionName.Switch, () => {
1,400✔
633
            // Handle switch function with variable number of case-result pairs
7✔
634
            const expression = params[0];
7✔
635
            const cases: Array<{ case: string; result: string }> = [];
7✔
636
            let defaultResult: string | undefined;
7✔
637

638
            type SwitchResultEntry = {
639
              sql: string;
640
              ctx: ExprContext;
641
              type: 'string' | 'number' | 'boolean' | 'datetime' | 'unknown';
642
            };
643

644
            const resultEntries: SwitchResultEntry[] = [];
7✔
645

646
            // Helper to normalize blank-like results when other branches require stricter typing
7✔
647
            const normalizeBlankResults = () => {
7✔
648
              const hasNumber = resultEntries.some((entry) => entry.type === 'number');
7✔
649
              const hasBoolean = resultEntries.some((entry) => entry.type === 'boolean');
7✔
650
              const hasDatetime = resultEntries.some((entry) => entry.type === 'datetime');
7✔
651

652
              const requiresNumeric = hasNumber;
7✔
653
              const requiresBoolean = hasBoolean;
7✔
654
              const requiresDatetime = hasDatetime;
7✔
655

656
              const shouldNullifyEntry = (entry: SwitchResultEntry): boolean => {
7✔
657
                const isBlank = this.isBlankLikeExpression(entry.ctx) || entry.sql.trim() === "''";
21✔
658

659
                if (!isBlank) {
21✔
660
                  return false;
16✔
661
                }
16✔
662

663
                if (requiresNumeric && entry.type !== 'number') {
21✔
664
                  return true;
5✔
665
                }
5✔
666

667
                if (requiresBoolean && entry.type !== 'boolean') {
21✔
668
                  return true;
×
669
                }
×
670

671
                if (requiresDatetime && entry.type !== 'datetime') {
21✔
672
                  return true;
×
673
                }
×
674

675
                return false;
×
676
              };
×
677

678
              for (const entry of resultEntries) {
7✔
679
                if (shouldNullifyEntry(entry)) {
21✔
680
                  entry.sql = 'NULL';
5✔
681
                }
5✔
682
              }
21✔
683
            };
7✔
684

685
            // Collect case/result pairs and default (if any)
7✔
686
            for (let i = 1; i < params.length; i += 2) {
7✔
687
              if (i + 1 < params.length) {
21✔
688
                const resultCtx = exprContexts[i + 1];
14✔
689
                resultEntries.push({
14✔
690
                  sql: params[i + 1],
14✔
691
                  ctx: resultCtx,
14✔
692
                  type: this.inferExpressionType(resultCtx),
14✔
693
                });
14✔
694

695
                cases.push({
14✔
696
                  case: params[i],
14✔
697
                  result: params[i + 1],
14✔
698
                });
14✔
699
              } else {
21✔
700
                const resultCtx = exprContexts[i];
7✔
701
                resultEntries.push({
7✔
702
                  sql: params[i],
7✔
703
                  ctx: resultCtx,
7✔
704
                  type: this.inferExpressionType(resultCtx),
7✔
705
                });
7✔
706
                defaultResult = params[i];
7✔
707
              }
7✔
708
            }
21✔
709

710
            // Normalize blank results only after we have collected all branch types
7✔
711
            normalizeBlankResults();
7✔
712

713
            if (this.inferExpressionType(ctx) === 'string') {
7✔
714
              for (const entry of resultEntries) {
2✔
715
                entry.sql = this.coerceCaseBranchToText(entry.sql);
6✔
716
              }
6✔
717
            }
2✔
718

719
            // Apply normalized SQL back to cases/default
7✔
720
            let resultIndex = 0;
7✔
721
            for (let i = 0; i < cases.length; i++) {
7✔
722
              cases[i] = {
14✔
723
                case: cases[i].case,
14✔
724
                result: resultEntries[resultIndex++].sql,
14✔
725
              };
14✔
726
            }
14✔
727

728
            if (defaultResult !== undefined) {
7✔
729
              defaultResult = resultEntries[resultIndex]?.sql;
7✔
730
            }
7✔
731

732
            return this.formulaQuery.switch(expression, cases, defaultResult);
7✔
733
          })
7✔
734

735
          // Array Functions
1,400✔
736
          .with(FunctionName.Count, () => this.formulaQuery.count(params))
1,400✔
737
          .with(FunctionName.CountA, () => this.formulaQuery.countA(params))
1,400✔
738
          .with(FunctionName.CountAll, () => this.formulaQuery.countAll(params[0]))
1,400✔
739
          .with(FunctionName.ArrayJoin, () => this.formulaQuery.arrayJoin(params[0], params[1]))
1,400✔
740
          .with(FunctionName.ArrayUnique, () => this.formulaQuery.arrayUnique(params[0]))
1,400✔
741
          .with(FunctionName.ArrayFlatten, () => this.formulaQuery.arrayFlatten(params[0]))
1,400✔
742
          .with(FunctionName.ArrayCompact, () => this.formulaQuery.arrayCompact(params[0]))
1,400✔
743

744
          // System Functions
1,400✔
745
          .with(FunctionName.RecordId, () => this.formulaQuery.recordId())
1,400✔
746
          .with(FunctionName.AutoNumber, () => this.formulaQuery.autoNumber())
1,400✔
747
          .with(FunctionName.TextAll, () => this.formulaQuery.textAll(params[0]))
1,400✔
748

749
          .otherwise((fn) => {
1,400✔
750
            throw new Error(`Unsupported function: ${fn}`);
×
751
          })
×
752
      );
753
    };
1,400✔
754

755
    try {
1,441✔
756
      return execute();
1,441✔
757
    } finally {
1,441✔
758
      this.formulaQuery.setCallMetadata(undefined);
1,441✔
759
    }
1,441✔
760
  }
1,441✔
761

762
  visitBinaryOp(ctx: BinaryOpContext): string {
3,228✔
763
    const exprContexts = [ctx.expr(0), ctx.expr(1)];
2,957✔
764
    const paramMetadata = exprContexts.map((exprCtx) => this.buildParamMetadata(exprCtx));
2,957✔
765
    this.formulaQuery.setCallMetadata(paramMetadata);
2,957✔
766

767
    try {
2,957✔
768
      let left = exprContexts[0].accept(this);
2,957✔
769
      let right = exprContexts[1].accept(this);
2,957✔
770
      const operator = ctx._op;
2,957✔
771

772
      // For comparison operators, ensure operands are comparable to avoid
2,957✔
773
      // Postgres errors like "operator does not exist: text > integer".
2,957✔
774
      // If one side is number and the other is string, safely cast the string
2,957✔
775
      // side to numeric (driver-aware) before building the comparison.
2,957✔
776
      const leftType = this.inferExpressionType(exprContexts[0]);
2,957✔
777
      const rightType = this.inferExpressionType(exprContexts[1]);
2,957✔
778
      const needsNumericCoercion = (op: string) =>
2,957✔
779
        ['>', '<', '>=', '<=', '=', '!=', '<>'].includes(op);
2,957✔
780
      if (operator.text && needsNumericCoercion(operator.text)) {
2,957✔
781
        if (leftType === 'number' && rightType === 'string') {
666✔
782
          right = this.safeCastToNumeric(right);
3✔
783
        } else if (leftType === 'string' && rightType === 'number') {
666✔
784
          left = this.safeCastToNumeric(left);
8✔
785
        }
8✔
786
      }
666✔
787

788
      // For arithmetic operators (except '+'), coerce string operands to numeric
2,957✔
789
      // so expressions like "text * 3" or "'10' / '2'" work without errors in generated columns.
2,957✔
790
      const needsArithmeticNumericCoercion = (op: string) => ['*', '/', '-', '%'].includes(op);
2,957✔
791
      if (operator.text && needsArithmeticNumericCoercion(operator.text)) {
2,957✔
792
        if (leftType === 'string') {
937✔
793
          left = this.safeCastToNumeric(left);
21✔
794
        }
21✔
795
        if (rightType === 'string') {
937✔
796
          right = this.safeCastToNumeric(right);
9✔
797
        }
9✔
798
      }
937✔
799

800
      return match(operator.text)
2,957✔
801
        .with('+', () => {
2,957✔
802
          // Check if either operand is a string type for concatenation
634✔
803
          const _leftType = this.inferExpressionType(exprContexts[0]);
634✔
804
          const _rightType = this.inferExpressionType(exprContexts[1]);
634✔
805

806
          const forceNumericAddition = this.shouldForceNumericAddition();
634✔
807

808
          if (
634✔
809
            !forceNumericAddition &&
634✔
810
            (_leftType === 'string' ||
158✔
811
              _rightType === 'string' ||
128✔
812
              _leftType === 'datetime' ||
119✔
813
              _rightType === 'datetime')
158✔
814
          ) {
634✔
815
            const coercedLeft = this.coerceToStringForConcatenation(left, ctx.expr(0), _leftType);
39✔
816
            const coercedRight = this.coerceToStringForConcatenation(
39✔
817
              right,
39✔
818
              ctx.expr(1),
39✔
819
              _rightType
39✔
820
            );
821
            return this.formulaQuery.stringConcat(coercedLeft, coercedRight);
39✔
822
          }
39✔
823

824
          return this.formulaQuery.add(left, right);
595✔
825
        })
595✔
826
        .with('-', () => this.formulaQuery.subtract(left, right))
2,957✔
827
        .with('*', () => this.formulaQuery.multiply(left, right))
2,957✔
828
        .with('/', () => this.formulaQuery.divide(left, right))
2,957✔
829
        .with('%', () => this.formulaQuery.modulo(left, right))
2,957✔
830
        .with('>', () => this.formulaQuery.greaterThan(left, right))
2,957✔
831
        .with('<', () => this.formulaQuery.lessThan(left, right))
2,957✔
832
        .with('>=', () => this.formulaQuery.greaterThanOrEqual(left, right))
2,957✔
833
        .with('<=', () => this.formulaQuery.lessThanOrEqual(left, right))
2,957✔
834
        .with('=', () => this.formulaQuery.equal(left, right))
2,957✔
835
        .with('!=', '<>', () => this.formulaQuery.notEqual(left, right))
2,957✔
836
        .with('&&', () => {
2,957✔
837
          const normalizedLeft = this.normalizeBooleanExpression(left, ctx.expr(0));
×
838
          const normalizedRight = this.normalizeBooleanExpression(right, ctx.expr(1));
×
839
          return this.formulaQuery.logicalAnd(normalizedLeft, normalizedRight);
×
840
        })
×
841
        .with('||', () => {
2,957✔
842
          const normalizedLeft = this.normalizeBooleanExpression(left, ctx.expr(0));
×
843
          const normalizedRight = this.normalizeBooleanExpression(right, ctx.expr(1));
×
844
          return this.formulaQuery.logicalOr(normalizedLeft, normalizedRight);
×
845
        })
×
846
        .with('&', () => {
2,957✔
847
          // Always treat & as string concatenation to avoid type issues
720✔
848
          const leftType = this.inferExpressionType(ctx.expr(0));
720✔
849
          const rightType = this.inferExpressionType(ctx.expr(1));
720✔
850
          const coercedLeft = this.coerceToStringForConcatenation(left, ctx.expr(0), leftType);
720✔
851
          const coercedRight = this.coerceToStringForConcatenation(right, ctx.expr(1), rightType);
720✔
852
          return this.formulaQuery.stringConcat(coercedLeft, coercedRight);
720✔
853
        })
720✔
854
        .otherwise((op) => {
2,957✔
855
          throw new Error(`Unsupported binary operator: ${op}`);
×
856
        });
×
857
    } finally {
2,957✔
858
      this.formulaQuery.setCallMetadata(undefined);
2,957✔
859
    }
2,957✔
860
  }
2,957✔
861

862
  private normalizeFunctionParamsForMultiplicity(
3,228✔
863
    fnName: FunctionName,
1,441✔
864
    params: string[],
1,441✔
865
    exprContexts: ExprContext[]
1,441✔
866
  ): string[] {
1,441✔
867
    const funcMeta = FUNCTIONS[fnName];
1,441✔
868
    if (!funcMeta) {
1,441!
869
      return params;
×
870
    }
×
871

872
    return params.map((paramSql, index) => {
1,441✔
873
      if (funcMeta.acceptMultipleValue) {
3,328✔
874
        return paramSql;
2,704✔
875
      }
2,704✔
876

877
      if (this.shouldPreserveMultiValueParam(fnName, exprContexts[index], index, paramSql)) {
3,328✔
878
        return paramSql;
135✔
879
      }
135✔
880

881
      return this.reduceMultiFieldReferenceParam(exprContexts[index], paramSql);
489✔
882
    });
489✔
883
  }
1,441✔
884

885
  private tryBuildMultiValueAggregator(
3,228✔
886
    fnName: FunctionName,
1,441✔
887
    params: string[],
1,441✔
888
    exprContexts: ExprContext[]
1,441✔
889
  ): string | null {
1,441✔
890
    if (!exprContexts[0] || this.dialect?.driver !== DriverClient.Pg) {
1,441✔
891
      return null;
35✔
892
    }
35✔
893

894
    const isMulti = this.isMultiValueExpr(exprContexts[0], params[0]);
1,406✔
895
    if (!isMulti) {
1,436✔
896
      return null;
1,144✔
897
    }
1,144✔
898

899
    switch (fnName) {
262✔
900
      case FunctionName.DatetimeFormat: {
1,011✔
901
        const formatExpr = params[1] ?? `'YYYY-MM-DD HH:mm'`;
5!
902
        return this.buildPgDatetimeFormatAggregator(params[0], formatExpr);
5✔
903
      }
5✔
904
      case FunctionName.Value:
1,441✔
905
        return this.buildPgNumericAggregator(params[0], (scalarText) =>
4✔
906
          this.formulaQuery.value(scalarText)
4✔
907
        );
908
      case FunctionName.Abs:
1,441!
909
        return this.buildPgNumericAggregator(params[0], (scalarText) =>
×
910
          this.formulaQuery.abs(this.formulaQuery.value(scalarText))
×
911
        );
912
      case FunctionName.Datestr:
1,441✔
913
        return this.buildPgDatetimeScalarAggregator(params[0], (scalar) =>
2✔
914
          this.formulaQuery.datestr(scalar)
2✔
915
        );
916
      case FunctionName.Timestr:
1,441✔
917
        return this.buildPgDatetimeScalarAggregator(params[0], (scalar) =>
2✔
918
          this.formulaQuery.timestr(scalar)
2✔
919
        );
920
      case FunctionName.Day:
1,441✔
921
        return this.buildPgDatetimeScalarAggregator(params[0], (scalar) =>
2✔
922
          this.formulaQuery.day(scalar)
2✔
923
        );
924
      case FunctionName.Month:
1,441✔
925
        return this.buildPgDatetimeScalarAggregator(params[0], (scalar) =>
2✔
926
          this.formulaQuery.month(scalar)
2✔
927
        );
928
      case FunctionName.Year:
1,441✔
929
        return this.buildPgDatetimeScalarAggregator(params[0], (scalar) =>
2✔
930
          this.formulaQuery.year(scalar)
2✔
931
        );
932
      case FunctionName.Weekday:
1,441✔
933
        return this.buildPgDatetimeScalarAggregator(params[0], (scalar) =>
2✔
934
          this.formulaQuery.weekday(scalar)
2✔
935
        );
936
      case FunctionName.WeekNum:
1,441✔
937
        return this.buildPgDatetimeScalarAggregator(params[0], (scalar) =>
2✔
938
          this.formulaQuery.weekNum(scalar)
2✔
939
        );
940
      case FunctionName.Hour:
1,441✔
941
        return this.buildPgDatetimeScalarAggregator(params[0], (scalar) =>
2✔
942
          this.formulaQuery.hour(scalar)
2✔
943
        );
944
      case FunctionName.Minute:
1,441✔
945
        return this.buildPgDatetimeScalarAggregator(params[0], (scalar) =>
2✔
946
          this.formulaQuery.minute(scalar)
2✔
947
        );
948
      case FunctionName.Second:
1,441✔
949
        return this.buildPgDatetimeScalarAggregator(params[0], (scalar) =>
2✔
950
          this.formulaQuery.second(scalar)
2✔
951
        );
952
      case FunctionName.FromNow:
1,441!
953
        return this.buildPgDatetimeScalarAggregator(params[0], (scalar) =>
×
954
          this.formulaQuery.fromNow(scalar)
×
955
        );
956
      case FunctionName.ToNow:
1,441!
957
        return this.buildPgDatetimeScalarAggregator(params[0], (scalar) =>
×
958
          this.formulaQuery.toNow(scalar)
×
959
        );
960
      case FunctionName.Round:
1,441✔
961
        return this.buildPgNumericScalarAggregator(params[0], (scalar) =>
2✔
962
          this.formulaQuery.round(scalar, params[1] ?? '0')
2!
963
        );
964
      case FunctionName.RoundUp:
1,441✔
965
        return this.buildPgNumericScalarAggregator(params[0], (scalar) =>
2✔
966
          this.formulaQuery.roundUp(scalar, params[1] ?? '0')
2!
967
        );
968
      case FunctionName.RoundDown:
1,441✔
969
        return this.buildPgNumericScalarAggregator(params[0], (scalar) =>
2✔
970
          this.formulaQuery.roundDown(scalar, params[1] ?? '0')
2!
971
        );
972
      case FunctionName.Floor:
1,441✔
973
        return this.buildPgNumericScalarAggregator(params[0], (scalar) =>
2✔
974
          this.formulaQuery.floor(scalar)
2✔
975
        );
976
      case FunctionName.Ceiling:
1,441✔
977
        return this.buildPgNumericScalarAggregator(params[0], (scalar) =>
2✔
978
          this.formulaQuery.ceiling(scalar)
2✔
979
        );
980
      case FunctionName.Int:
1,441✔
981
        return this.buildPgNumericScalarAggregator(params[0], (scalar) =>
2✔
982
          this.formulaQuery.int(scalar)
2✔
983
        );
984
      default:
1,441✔
985
        return null;
221✔
986
    }
1,441✔
987
  }
1,441✔
988

989
  private shouldPreserveMultiValueParam(
3,228✔
990
    fnName: FunctionName,
624✔
991
    exprCtx: ExprContext,
624✔
992
    index: number,
624✔
993
    paramSql: string
624✔
994
  ): boolean {
624✔
995
    if (MULTI_VALUE_AGGREGATED_FUNCTIONS.has(fnName) && index === 0) {
624✔
996
      return true;
129✔
997
    }
129✔
998

999
    return this.isMultiValueExpr(exprCtx, paramSql);
495✔
1000
  }
495✔
1001

1002
  private reduceMultiFieldReferenceParam(exprCtx: ExprContext, paramSql: string): string {
3,228✔
1003
    if (!this.isMultiValueExpr(exprCtx, paramSql)) {
489✔
1004
      return paramSql;
489✔
1005
    }
489!
1006

1007
    const fieldInfo = this.getFieldInfoFromExpr(exprCtx);
×
1008
    if (fieldInfo) {
×
1009
      return this.extractSingleValueFromMultiReference(paramSql, fieldInfo);
×
1010
    }
×
1011
    return paramSql;
×
1012
  }
×
1013

1014
  private getFieldInfoFromExpr(exprCtx: ExprContext): FieldCore | undefined {
3,228✔
1015
    if (!exprCtx) {
2,390!
1016
      return undefined;
×
1017
    }
×
1018

1019
    if (exprCtx instanceof BracketsContext) {
2,390!
1020
      return this.getFieldInfoFromExpr(exprCtx.expr());
×
1021
    }
×
1022

1023
    if (exprCtx instanceof FieldReferenceCurlyContext) {
2,390✔
1024
      const normalizedFieldId = extractFieldReferenceId(exprCtx);
412✔
1025
      const rawToken = getFieldReferenceTokenText(exprCtx);
412✔
1026
      const fieldId = normalizedFieldId ?? rawToken?.slice(1, -1).trim() ?? '';
412!
1027
      if (!fieldId) {
412!
1028
        return undefined;
×
1029
      }
×
1030
      return this.context.table.getField(fieldId);
412✔
1031
    }
412✔
1032

1033
    return undefined;
1,978✔
1034
  }
1,978✔
1035

1036
  private isMultiValueField(fieldInfo?: FieldCore): boolean {
3,228✔
1037
    if (!fieldInfo) {
2,422✔
1038
      return false;
1,978✔
1039
    }
1,978✔
1040

1041
    if ((fieldInfo as unknown as { isMultipleCellValue?: boolean }).isMultipleCellValue) {
2,422✔
1042
      return true;
83✔
1043
    }
83✔
1044

1045
    if (fieldInfo.dbFieldType === DbFieldType.Json) {
2,401✔
1046
      return true;
32✔
1047
    }
32✔
1048

1049
    if (MULTI_VALUE_FIELD_TYPES.has(fieldInfo.type as FieldType)) {
2,401!
1050
      return true;
×
1051
    }
✔
1052

1053
    const lookupHolder = fieldInfo as unknown as {
329✔
1054
      isLookup?: boolean;
1055
      dbFieldName?: string;
1056
      lookupOptions?: { linkFieldId?: string };
1057
    };
1058
    if (
329✔
1059
      lookupHolder.isLookup === true ||
329✔
1060
      lookupHolder.dbFieldName?.startsWith('lookup_') ||
321✔
1061
      lookupHolder.dbFieldName?.startsWith('conditional_lookup_')
321✔
1062
    ) {
2,422✔
1063
      return true;
8✔
1064
    }
8✔
1065

1066
    if (lookupHolder.lookupOptions?.linkFieldId) {
2,422✔
1067
      const linkField = this.context.table.getField(lookupHolder.lookupOptions.linkFieldId);
×
1068
      const linkIsMulti = this.isLinkFieldMulti(linkField as FieldCore | undefined);
×
1069
      if (linkIsMulti) {
×
1070
        return true;
×
1071
      }
×
1072
    }
✔
1073

1074
    return false;
321✔
1075
  }
321✔
1076

1077
  private isLinkFieldMulti(linkField?: FieldCore): boolean {
3,228✔
1078
    if (!linkField) {
×
1079
      return false;
×
1080
    }
×
1081
    if ((linkField as unknown as { isMultipleCellValue?: boolean })?.isMultipleCellValue) {
×
1082
      return true;
×
1083
    }
×
1084
    const relationship = (
×
1085
      linkField as unknown as {
×
1086
        options?: { relationship?: Relationship };
1087
      }
1088
    ).options?.relationship;
×
1089
    if (!relationship) {
×
1090
      return false;
×
1091
    }
×
1092
    return relationship === Relationship.ManyMany || relationship === Relationship.OneMany;
×
1093
  }
×
1094

1095
  private isMultiValueExpr(exprCtx: ExprContext, paramSql?: string): boolean {
3,228✔
1096
    const fieldInfo = this.getFieldInfoFromExpr(exprCtx);
2,390✔
1097
    if (this.isMultiValueField(fieldInfo)) {
2,390✔
1098
      return true;
113✔
1099
    }
113✔
1100

1101
    if (paramSql) {
2,277✔
1102
      const normalized = paramSql.toLowerCase();
2,277✔
1103
      if (normalized.includes('lookup_') || normalized.includes('link_value')) {
2,277✔
1104
        return true;
155✔
1105
      }
155✔
1106
    }
2,277✔
1107

1108
    return false;
2,122✔
1109
  }
2,122✔
1110

1111
  private extractSingleValueFromMultiReference(expr: string, fieldInfo: FieldCore): string {
3,228✔
1112
    if (!this.dialect) {
×
1113
      return expr;
×
1114
    }
×
1115

1116
    switch (this.dialect.driver) {
×
1117
      case DriverClient.Pg:
×
1118
        return this.buildPgSingleValueExtractor(expr, fieldInfo);
×
1119
      case DriverClient.Sqlite:
×
1120
        return this.buildSqliteSingleValueExtractor(expr);
×
1121
      default:
×
1122
        return expr;
×
1123
    }
×
1124
  }
×
1125

1126
  private buildSqliteSingleValueExtractor(expr: string): string {
3,228✔
1127
    // SQLite formulas already treat multi-value columns as JSON text during coercion.
×
1128
    // Returning the original expression keeps existing behaviour consistent.
×
1129
    return expr;
×
1130
  }
×
1131

1132
  private buildPgSingleValueExtractor(expr: string, _fieldInfo: FieldCore): string {
3,228✔
NEW
1133
    const fieldInfo = _fieldInfo;
×
UNCOV
1134
    const normalizedJson = this.normalizeMultiValueExprToJson(expr);
×
1135

1136
    const firstElement = `(SELECT elem
×
1137
      FROM jsonb_array_elements(${normalizedJson}) WITH ORDINALITY AS t(elem, ord)
×
1138
      WHERE jsonb_typeof(elem) <> 'null'
1139
      ORDER BY ord
1140
      LIMIT 1
1141
    )`;
×
1142

1143
    const scalarJson = `(CASE
×
1144
      WHEN ${normalizedJson} IS NULL THEN NULL::jsonb
×
1145
      WHEN jsonb_typeof(${normalizedJson}) = 'array' THEN ${firstElement}
×
1146
      ELSE ${normalizedJson}
×
1147
    END)`;
×
1148

1149
    return `(CASE
×
1150
      WHEN ${scalarJson} IS NULL THEN NULL
×
1151
      WHEN jsonb_typeof(${scalarJson}) = 'object' THEN COALESCE(
×
1152
        ${scalarJson}->>'title',
×
1153
        ${scalarJson}->>'name',
×
1154
        (${scalarJson})::text
×
1155
      )
1156
      WHEN jsonb_typeof(${scalarJson}) = 'array' THEN NULL
×
NEW
1157
      ELSE ${this.formatScalarDatetimeIfNeeded(`${scalarJson} #>> '{}'`, fieldInfo)}
×
1158
    END)`;
×
1159
  }
×
1160

1161
  private formatScalarDatetimeIfNeeded(scalar: string, fieldInfo: FieldCore): string {
3,228✔
NEW
1162
    if (this.context?.isGeneratedColumn) {
×
NEW
1163
      return scalar;
×
NEW
1164
    }
×
NEW
1165
    const isDatetimeCell =
×
NEW
1166
      (fieldInfo as unknown as { cellValueType?: CellValueType })?.cellValueType ===
×
NEW
1167
        CellValueType.DateTime || fieldInfo.dbFieldType === DbFieldType.DateTime;
×
NEW
1168
    const formatting = (fieldInfo as unknown as { options?: { formatting?: IDatetimeFormatting } })
×
NEW
1169
      ?.options?.formatting;
×
1170

NEW
1171
    if (!isDatetimeCell || !this.dialect || typeof this.dialect.formatDate !== 'function') {
×
NEW
1172
      return scalar;
×
NEW
1173
    }
×
1174

NEW
1175
    const fallBackFormatting: IDatetimeFormatting = {
×
NEW
1176
      date: DateFormattingPreset.ISO,
×
NEW
1177
      time: TimeFormatting.None,
×
NEW
1178
      timeZone: this.context?.timeZone ?? 'UTC',
×
NEW
1179
    };
×
1180

NEW
1181
    return this.dialect.formatDate(scalar, formatting ?? fallBackFormatting);
×
NEW
1182
  }
×
1183

1184
  private normalizeMultiValueExprToJson(expr: string): string {
3,228✔
1185
    const baseExpr = `(${expr})`;
41✔
1186
    const coercedJson = `(CASE
41✔
1187
      WHEN ${baseExpr} IS NULL THEN NULL::jsonb
41✔
1188
      WHEN pg_typeof(${baseExpr}) = 'jsonb'::regtype THEN (${baseExpr})::text::jsonb
41✔
1189
      WHEN pg_typeof(${baseExpr}) = 'json'::regtype THEN (${baseExpr})::text::jsonb
41✔
1190
      WHEN pg_typeof(${baseExpr}) IN ('text', 'varchar', 'bpchar', 'character varying', 'unknown') THEN
41✔
1191
        CASE
1192
          WHEN NULLIF(BTRIM((${baseExpr})::text), '') IS NULL THEN NULL::jsonb
41✔
1193
          WHEN LEFT(BTRIM((${baseExpr})::text), 1) = '[' THEN (${baseExpr})::text::jsonb
41✔
1194
          ELSE jsonb_build_array(to_jsonb(${baseExpr}))
41✔
1195
        END
1196
      ELSE to_jsonb(${baseExpr})
41✔
1197
    END)`;
41✔
1198
    return `(CASE
41✔
1199
      WHEN ${coercedJson} IS NULL THEN NULL::jsonb
41✔
1200
      WHEN jsonb_typeof(${coercedJson}) = 'array' THEN ${coercedJson}
41✔
1201
      ELSE jsonb_build_array(${coercedJson})
41✔
1202
    END)`;
41✔
1203
  }
41✔
1204

1205
  private extractJsonScalarText(elemRef: string): string {
3,228✔
1206
    return `(CASE
41✔
1207
      WHEN jsonb_typeof(${elemRef}) = 'object' THEN COALESCE(${elemRef}->>'title', ${elemRef}->>'name', ${elemRef} #>> '{}')
41✔
1208
      WHEN jsonb_typeof(${elemRef}) = 'array' THEN NULL
41✔
1209
      ELSE ${elemRef} #>> '{}'
41✔
1210
    END)`;
41✔
1211
  }
41✔
1212

1213
  private buildPgNumericAggregator(
3,228✔
1214
    valueExpr: string,
4✔
1215
    buildNumericExpr: (scalarTextExpr: string) => string
4✔
1216
  ): string {
4✔
1217
    const normalizedJson = this.normalizeMultiValueExprToJson(valueExpr);
4✔
1218
    const scalarText = this.extractJsonScalarText('elem');
4✔
1219
    const numericExpr = buildNumericExpr(scalarText);
4✔
1220
    const formattedExpr = `(CASE WHEN ${numericExpr} IS NULL THEN NULL ELSE ${numericExpr} END)`;
4✔
1221
    const aggregated = this.dialect!.stringAggregate(formattedExpr, ', ', 'ord');
4✔
1222
    return `(CASE
4✔
1223
      WHEN ${normalizedJson} IS NULL THEN NULL
4✔
1224
      ELSE (
1225
        SELECT ${aggregated}
4✔
1226
        FROM jsonb_array_elements(${normalizedJson}) WITH ORDINALITY AS t(elem, ord)
4✔
1227
      )
1228
    END)`;
4✔
1229
  }
4✔
1230

1231
  private buildPgDatetimeFormatAggregator(valueExpr: string, formatExpr: string): string {
3,228✔
1232
    return this.buildPgDatetimeScalarAggregator(valueExpr, (scalar) =>
5✔
1233
      this.formulaQuery.datetimeFormat(scalar, formatExpr)
5✔
1234
    );
1235
  }
5✔
1236

1237
  private buildPgNumericScalarAggregator(
3,228✔
1238
    valueExpr: string,
12✔
1239
    buildScalarExpr: (numericScalar: string) => string
12✔
1240
  ): string {
12✔
1241
    const normalizedJson = this.normalizeMultiValueExprToJson(valueExpr);
12✔
1242
    const elementScalar = this.extractJsonScalarText('elem');
12✔
1243
    const sanitizedScalar = `NULLIF(${elementScalar}, '')`;
12✔
1244
    const numericScalar = this.formulaQuery.value(sanitizedScalar);
12✔
1245
    const computedExpr = buildScalarExpr(numericScalar);
12✔
1246
    const safeExpr = `(CASE WHEN ${numericScalar} IS NULL THEN NULL ELSE (${computedExpr})::text END)`;
12✔
1247
    const aggregated = this.dialect!.stringAggregate(safeExpr, ', ', 'ord');
12✔
1248
    return `(CASE
12✔
1249
      WHEN ${normalizedJson} IS NULL THEN NULL
12✔
1250
      ELSE (
1251
        SELECT ${aggregated}
12✔
1252
        FROM jsonb_array_elements(${normalizedJson}) WITH ORDINALITY AS t(elem, ord)
12✔
1253
      )
1254
    END)`;
12✔
1255
  }
12✔
1256

1257
  private buildPgDatetimeScalarAggregator(
3,228✔
1258
    valueExpr: string,
25✔
1259
    buildScalarExpr: (sanitizedScalar: string) => string
25✔
1260
  ): string {
25✔
1261
    const normalizedJson = this.normalizeMultiValueExprToJson(valueExpr);
25✔
1262
    const elementScalar = this.extractJsonScalarText('elem');
25✔
1263
    const sanitizedScalar = `NULLIF(${elementScalar}, '')`;
25✔
1264
    const computedExpr = buildScalarExpr(sanitizedScalar);
25✔
1265
    const safeExpr = `(CASE WHEN ${sanitizedScalar} IS NULL THEN NULL ELSE (${computedExpr})::text END)`;
25✔
1266
    const aggregated = this.dialect!.stringAggregate(safeExpr, ', ', 'ord');
25✔
1267
    return `(CASE
25✔
1268
      WHEN ${normalizedJson} IS NULL THEN NULL
25✔
1269
      ELSE (
1270
        SELECT ${aggregated}
25✔
1271
        FROM jsonb_array_elements(${normalizedJson}) WITH ORDINALITY AS t(elem, ord)
25✔
1272
      )
1273
    END)`;
25✔
1274
  }
25✔
1275

1276
  /**
3,228✔
1277
   * Safely cast an expression to numeric for comparisons.
1278
   * For PostgreSQL, avoid runtime errors by returning NULL for non-numeric text.
1279
   * For other drivers, fall back to a direct numeric cast.
1280
   */
3,228✔
1281
  private safeCastToNumeric(value: string): string {
3,228✔
1282
    return this.dialect!.coerceToNumericForCompare(value);
59✔
1283
  }
59✔
1284

1285
  /**
3,228✔
1286
   * Coerce values participating in string concatenation to textual representation when needed.
1287
   * Datetime operands are cast to string to mirror client-side behaviour and to avoid relying
1288
   * on database-specific implicit casts that may be non-immutable for generated columns.
1289
   */
3,228✔
1290
  private coerceToStringForConcatenation(
3,228✔
1291
    value: string,
1,609✔
1292
    exprCtx: ExprContext,
1,609✔
1293
    inferredType?: 'string' | 'number' | 'boolean' | 'datetime' | 'unknown'
1,609✔
1294
  ): string {
1,609✔
1295
    let normalizedValue = value;
1,609✔
1296
    let coercedMultiToString = false;
1,609✔
1297
    if (exprCtx instanceof FieldReferenceCurlyContext) {
1,609✔
1298
      const normalizedFieldId = extractFieldReferenceId(exprCtx);
32✔
1299
      const rawToken = getFieldReferenceTokenText(exprCtx);
32✔
1300
      const fieldId = normalizedFieldId ?? rawToken?.slice(1, -1).trim() ?? '';
32!
1301
      const fieldInfo = this.context.table.getField(fieldId);
32✔
1302
      const isMultiField = this.isMultiValueField(fieldInfo as FieldCore);
32✔
1303
      if (
32✔
1304
        fieldInfo &&
32✔
1305
        (fieldInfo as unknown as { cellValueType?: CellValueType })?.cellValueType ===
32✔
1306
          CellValueType.DateTime
32✔
1307
      ) {
32✔
1308
        // Keep a note that this value carries datetime semantics even when inferred as string
9✔
1309
        inferredType = inferredType === undefined ? 'datetime' : inferredType;
9!
1310
      }
9✔
1311
      if (isMultiField && this.dialect) {
32✔
1312
        // Normalize multi-value references (lookup, link, multi-select, etc.) into a deterministic
10✔
1313
        // comma-separated string so downstream text operations behave as expected.
10✔
1314
        normalizedValue = this.dialect.formatStringArray(value, { fieldInfo });
10✔
1315
        coercedMultiToString = true;
10✔
1316
      }
10✔
1317
    }
32✔
1318
    const type = coercedMultiToString
1,609✔
1319
      ? 'string'
10✔
1320
      : inferredType ?? this.inferExpressionType(exprCtx);
1,599✔
1321
    if (type === 'datetime') {
1,609✔
1322
      return this.formulaQuery.datetimeFormat(normalizedValue, "'YYYY-MM-DD'");
7✔
1323
    }
7✔
1324
    return normalizedValue;
1,602✔
1325
  }
1,602✔
1326

1327
  private shouldForceNumericAddition(): boolean {
3,228✔
1328
    const selectContext = this.context as ISelectFormulaConversionContext | undefined;
634✔
1329
    const targetType = selectContext?.targetDbFieldType;
634✔
1330
    return targetType === DbFieldType.Integer || targetType === DbFieldType.Real;
634✔
1331
  }
634✔
1332

1333
  private coerceCaseBranchToText(expr: string): string {
3,228✔
1334
    const trimmed = expr.trim();
1,120✔
1335
    const driver = this.context.driverClient ?? DriverClient.Pg;
1,120!
1336

1337
    // eslint-disable-next-line regexp/prefer-w
1,120✔
1338
    const nullPattern = /^NULL(?:::[a-zA-Z_][a-zA-Z0-9_\s]*)?$/i;
1,120✔
1339
    if (!trimmed || nullPattern.test(trimmed)) {
1,120!
1340
      return driver === DriverClient.Sqlite ? 'CAST(NULL AS TEXT)' : 'NULL::text';
×
1341
    }
×
1342

1343
    const isStringLiteral = trimmed.length >= 2 && trimmed.startsWith("'") && trimmed.endsWith("'");
1,120✔
1344
    if (isStringLiteral) {
1,120✔
1345
      return expr;
1,041✔
1346
    }
1,041✔
1347

1348
    if (driver === DriverClient.Sqlite) {
350!
1349
      const upper = trimmed.toUpperCase();
×
1350
      if (upper.startsWith('CAST(') && upper.endsWith('AS TEXT)')) {
×
1351
        return expr;
×
1352
      }
×
1353
      return `CAST(${expr} AS TEXT)`;
×
1354
    }
✔
1355

1356
    if (/::\s*text\b/i.test(trimmed) || /\)::\s*text\b/i.test(trimmed)) {
1,120✔
1357
      return expr;
68✔
1358
    }
68✔
1359

1360
    return `(${expr})::text`;
11✔
1361
  }
11✔
1362

1363
  private normalizeTextSliceCount(valueSql?: string, exprCtx?: ExprContext): string {
3,228✔
1364
    if (!valueSql || !exprCtx) {
18!
1365
      return '1';
×
1366
    }
×
1367

1368
    const type = this.inferExpressionType(exprCtx);
18✔
1369
    const driver = this.context.driverClient ?? DriverClient.Pg;
18!
1370

1371
    if (type === 'boolean') {
18!
1372
      if (driver === DriverClient.Sqlite) {
×
1373
        return `(CASE WHEN ${valueSql} IS NULL THEN 0 WHEN ${valueSql} <> 0 THEN 1 ELSE 0 END)`;
×
1374
      }
×
1375
      return `(CASE WHEN ${valueSql} IS NULL THEN 0 WHEN ${valueSql} THEN 1 ELSE 0 END)`;
×
1376
    }
×
1377

1378
    const numericExpr = this.safeCastToNumeric(valueSql);
18✔
1379
    const flooredExpr =
18✔
1380
      driver === DriverClient.Sqlite ? `CAST(${numericExpr} AS INTEGER)` : `FLOOR(${numericExpr})`;
18!
1381
    const flooredWrapped = `(${flooredExpr})`;
18✔
1382

1383
    return `(CASE
18✔
1384
      WHEN ${flooredWrapped} IS NULL THEN 0
18✔
1385
      WHEN ${flooredWrapped} < 0 THEN 0
18✔
1386
      ELSE ${flooredWrapped}
18✔
1387
    END)`;
18✔
1388
  }
18✔
1389
  private normalizeBooleanExpression(valueSql: string, exprCtx: ExprContext): string {
3,228✔
1390
    const type = this.inferExpressionType(exprCtx);
365✔
1391
    const driver = this.context.driverClient ?? DriverClient.Pg;
365!
1392

1393
    switch (type) {
365✔
1394
      case 'boolean':
365✔
1395
        if (driver === DriverClient.Sqlite) {
240!
1396
          return `(COALESCE((${valueSql}), 0) != 0)`;
×
1397
        }
×
1398
        return `(COALESCE(${this.normalizeBooleanFieldReference(valueSql, exprCtx) ?? valueSql}, FALSE))`;
240✔
1399
      case 'number': {
365✔
1400
        if (driver === DriverClient.Sqlite) {
99!
1401
          const numericExpr = this.safeCastToNumeric(valueSql);
×
1402
          return `(COALESCE(${numericExpr}, 0) <> 0)`;
×
1403
        }
×
1404
        const sanitized = `REGEXP_REPLACE(((${valueSql})::text), '[^0-9.+-]', '', 'g')`;
99✔
1405
        const numericCandidate = `(CASE
99✔
1406
          WHEN ${sanitized} ~ '^[-+]{0,1}(\\d+\\.\\d+|\\d+|\\.\\d+)$' THEN ${sanitized}::double precision
99✔
1407
          ELSE NULL
1408
        END)`;
99✔
1409
        return `(COALESCE(${numericCandidate}, 0) <> 0)`;
99✔
1410
      }
99✔
1411
      case 'string': {
365✔
1412
        if (driver === DriverClient.Sqlite) {
16!
1413
          const textExpr = `CAST(${valueSql} AS TEXT)`;
×
1414
          const trimmedExpr = `TRIM(${textExpr})`;
×
1415
          return `((${valueSql}) IS NOT NULL AND ${trimmedExpr} <> '' AND LOWER(${trimmedExpr}) <> 'null')`;
×
1416
        }
×
1417
        const textExpr = `(${valueSql})::text`;
16✔
1418
        const trimmedExpr = `TRIM(${textExpr})`;
16✔
1419
        return `((${valueSql}) IS NOT NULL AND ${trimmedExpr} <> '' AND LOWER(${trimmedExpr}) <> 'null')`;
16✔
1420
      }
16✔
1421
      case 'datetime':
365!
1422
        return `((${valueSql}) IS NOT NULL)`;
×
1423
      default:
365✔
1424
        return `((${valueSql}) IS NOT NULL)`;
10✔
1425
    }
365✔
1426
  }
365✔
1427

1428
  /**
3,228✔
1429
   * Coerce direct field references carrying boolean semantics into a proper boolean scalar.
1430
   * This keeps the SQL maintainable by leveraging schema metadata rather than runtime pg_typeof checks.
1431
   */
3,228✔
1432
  private normalizeBooleanFieldReference(valueSql: string, exprCtx: ExprContext): string | null {
3,228✔
1433
    if (!(exprCtx instanceof FieldReferenceCurlyContext)) {
240✔
1434
      return null;
231✔
1435
    }
231✔
1436

1437
    const normalizedFieldId = extractFieldReferenceId(exprCtx);
9✔
1438
    const rawToken = getFieldReferenceTokenText(exprCtx);
9✔
1439
    const fieldId = normalizedFieldId ?? rawToken?.slice(1, -1).trim() ?? '';
240!
1440
    const fieldInfo = this.context.table?.getField(fieldId);
240✔
1441
    if (!fieldInfo) {
240✔
1442
      return null;
×
1443
    }
✔
1444

1445
    const isBooleanField =
9✔
1446
      fieldInfo.dbFieldType === DbFieldType.Boolean || fieldInfo.cellValueType === 'boolean';
82!
1447
    if (!isBooleanField) {
240✔
1448
      return null;
×
1449
    }
✔
1450

1451
    return `((${valueSql}))::boolean`;
9✔
1452
  }
9✔
1453

1454
  private isBlankLikeExpression(ctx: ExprContext): boolean {
3,228✔
1455
    if (ctx instanceof StringLiteralContext) {
1,294✔
1456
      const raw = ctx.text;
9✔
1457
      if (raw.startsWith("'") && raw.endsWith("'")) {
9!
1458
        const unescaped = unescapeString(raw.slice(1, -1));
×
1459
        return unescaped === '';
×
1460
      }
×
1461
      return false;
9✔
1462
    }
9✔
1463

1464
    if (ctx instanceof FunctionCallContext) {
1,294✔
1465
      const rawName = ctx.func_name().text.toUpperCase();
3✔
1466
      const fnName = normalizeFunctionNameAlias(rawName) as FunctionName;
3✔
1467
      return fnName === FunctionName.Blank;
3✔
1468
    }
3✔
1469

1470
    return false;
1,282✔
1471
  }
1,282✔
1472
  /**
3,228✔
1473
   * Infer the type of an expression for type-aware operations
1474
   */
3,228✔
1475
  private inferExpressionType(
3,228✔
1476
    ctx: ExprContext
42,911✔
1477
  ): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {
42,911✔
1478
    // Handle literals
42,911✔
1479
    const literalType = this.inferLiteralType(ctx);
42,911✔
1480
    if (literalType !== 'unknown') {
42,911✔
1481
      return literalType;
11,156✔
1482
    }
11,156✔
1483

1484
    // Handle field references
31,755✔
1485
    if (ctx instanceof FieldReferenceCurlyContext) {
42,911✔
1486
      return this.inferFieldReferenceType(ctx);
8,011✔
1487
    }
8,011✔
1488

1489
    // Handle function calls
23,744✔
1490
    if (ctx instanceof FunctionCallContext) {
42,881✔
1491
      return this.inferFunctionReturnType(ctx);
1,833✔
1492
    }
1,833✔
1493

1494
    // Handle binary operations
21,911✔
1495
    if (ctx instanceof BinaryOpContext) {
42,881✔
1496
      return this.inferBinaryOperationType(ctx);
1,337✔
1497
    }
1,337✔
1498

1499
    // Handle parentheses - infer from inner expression
20,574✔
1500
    if (ctx instanceof BracketsContext) {
42,881✔
1501
      return this.inferExpressionType(ctx.expr());
128✔
1502
    }
128✔
1503

1504
    // Handle whitespace/comments - infer from inner expression
20,446✔
1505
    if (
20,446✔
1506
      ctx instanceof LeftWhitespaceOrCommentsContext ||
20,446✔
1507
      ctx instanceof RightWhitespaceOrCommentsContext
7,786✔
1508
    ) {
42,911✔
1509
      return this.inferExpressionType(ctx.expr());
20,444✔
1510
    }
20,444✔
1511

1512
    // Default to unknown for unhandled cases
2✔
1513
    return 'unknown';
2✔
1514
  }
2✔
1515

1516
  /**
3,228✔
1517
   * Infer type from literal contexts
1518
   */
3,228✔
1519
  private inferLiteralType(
3,228✔
1520
    ctx: ExprContext
42,911✔
1521
  ): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {
42,911✔
1522
    if (ctx instanceof StringLiteralContext) {
42,911✔
1523
      return 'string';
5,189✔
1524
    }
5,189✔
1525

1526
    if (ctx instanceof IntegerLiteralContext || ctx instanceof DecimalLiteralContext) {
42,911✔
1527
      return 'number';
5,967✔
1528
    }
5,967✔
1529

1530
    if (ctx instanceof BooleanLiteralContext) {
42,911✔
1531
      return 'boolean';
×
1532
    }
✔
1533

1534
    return 'unknown';
31,755✔
1535
  }
31,755✔
1536

1537
  /**
3,228✔
1538
   * Infer type from field reference
1539
   */
3,228✔
1540
  private inferFieldReferenceType(
3,228✔
1541
    ctx: FieldReferenceCurlyContext
8,011✔
1542
  ): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {
8,011✔
1543
    const { fieldInfo } = this.resolveFieldReference(ctx);
8,011✔
1544

1545
    if (!fieldInfo) {
8,011!
1546
      return 'unknown';
×
1547
    }
×
1548

1549
    if (
8,011✔
1550
      fieldInfo.isMultipleCellValue ||
8,011✔
1551
      (fieldInfo.isLookup && fieldInfo.dbFieldType === DbFieldType.Json)
8,011✔
1552
    ) {
8,011✔
1553
      // Multi-value fields (e.g. lookups) are materialized as JSON arrays even when the
160✔
1554
      // referenced cellValueType is datetime. Treat them as strings to avoid pushing JSON
160✔
1555
      // expressions through datetime-specific casts like ::timestamptz, which PostgreSQL
160✔
1556
      // rejects at runtime.
160✔
1557
      return 'string';
160✔
1558
    }
160✔
1559

1560
    if (!fieldInfo.type) {
8,002!
1561
      return 'unknown';
×
1562
    }
✔
1563

1564
    return this.mapFieldTypeToBasicType(fieldInfo);
7,851✔
1565
  }
7,851✔
1566

1567
  private resolveFieldReference(ctx: FieldReferenceCurlyContext): {
3,228✔
1568
    fieldId: string;
1569
    fieldInfo?: FieldCore;
1570
  } {
11,519✔
1571
    const normalizedFieldId = extractFieldReferenceId(ctx);
11,519✔
1572
    const rawToken = getFieldReferenceTokenText(ctx);
11,519✔
1573
    const fieldId = normalizedFieldId ?? rawToken?.slice(1, -1).trim() ?? '';
11,519!
1574
    const fieldInfo = this.context.table.getField(fieldId);
11,519✔
1575
    return { fieldId, fieldInfo };
11,519✔
1576
  }
11,519✔
1577

1578
  private buildParamMetadata(exprCtx: ExprContext): IFormulaParamMetadata {
3,228✔
1579
    const type = this.inferExpressionType(exprCtx) as FormulaParamType;
9,248✔
1580
    const fieldRef = this.extractFieldReferenceMetadata(exprCtx);
9,248✔
1581
    if (fieldRef) {
9,248✔
1582
      const { fieldId, fieldInfo } = fieldRef;
3,508✔
1583
      const fieldMetadata: IFormulaParamFieldMetadata = {
3,508✔
1584
        id: fieldId,
3,508✔
1585
        type: fieldInfo?.type as FieldType | undefined,
3,508✔
1586
        cellValueType: fieldInfo?.cellValueType,
3,508✔
1587
        isMultiple: Boolean(fieldInfo?.isMultipleCellValue),
3,508✔
1588
        isLookup: Boolean(fieldInfo?.isLookup),
3,508✔
1589
        dbFieldType: fieldInfo?.dbFieldType,
3,508✔
1590
      };
3,508✔
1591
      return {
3,508✔
1592
        type,
3,508✔
1593
        isFieldReference: true,
3,508✔
1594
        field: fieldMetadata,
3,508✔
1595
      };
3,508✔
1596
    }
3,508✔
1597
    return {
5,740✔
1598
      type,
5,740✔
1599
      isFieldReference: false,
5,740✔
1600
    };
5,740✔
1601
  }
5,740✔
1602

1603
  private extractFieldReferenceMetadata(
3,228✔
1604
    exprCtx: ExprContext
17,115✔
1605
  ): { fieldId: string; fieldInfo?: FieldCore } | undefined {
17,115✔
1606
    if (exprCtx instanceof FieldReferenceCurlyContext) {
17,115✔
1607
      return this.resolveFieldReference(exprCtx);
3,508✔
1608
    }
3,508✔
1609
    if (exprCtx instanceof BracketsContext) {
17,115✔
1610
      return this.extractFieldReferenceMetadata(exprCtx.expr());
48✔
1611
    }
48✔
1612
    if (exprCtx instanceof LeftWhitespaceOrCommentsContext) {
17,115✔
1613
      return this.extractFieldReferenceMetadata(exprCtx.expr());
4,860✔
1614
    }
4,860✔
1615
    if (exprCtx instanceof RightWhitespaceOrCommentsContext) {
16,932✔
1616
      return this.extractFieldReferenceMetadata(exprCtx.expr());
2,959✔
1617
    }
2,959✔
1618
    return undefined;
5,740✔
1619
  }
5,740✔
1620

1621
  /**
3,228✔
1622
   * Map field types to basic types
1623
   */
3,228✔
1624
  private mapFieldTypeToBasicType(
3,228✔
1625
    fieldInfo: FieldCore
7,851✔
1626
  ): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {
7,851✔
1627
    const { type, cellValueType } = fieldInfo;
7,851✔
1628
    const typeEnum = type as FieldType;
7,851✔
1629

1630
    if (STRING_FIELD_TYPES.has(typeEnum)) {
7,851✔
1631
      return 'string';
2,545✔
1632
    }
2,545✔
1633

1634
    if (DATETIME_FIELD_TYPES.has(typeEnum)) {
7,851✔
1635
      return 'datetime';
103✔
1636
    }
103✔
1637

1638
    if (NUMBER_FIELD_TYPES.has(typeEnum)) {
7,625✔
1639
      return 'number';
4,906✔
1640
    }
4,906✔
1641

1642
    if (typeEnum === FieldType.Checkbox) {
5,108✔
1643
      return 'boolean';
44✔
1644
    }
44✔
1645

1646
    if (
253✔
1647
      typeEnum === FieldType.Formula ||
253✔
1648
      typeEnum === FieldType.Rollup ||
7,851✔
1649
      typeEnum === FieldType.ConditionalRollup
6✔
1650
    ) {
7,851✔
1651
      if (cellValueType) {
253✔
1652
        return this.mapCellValueTypeToBasicType(cellValueType);
253✔
1653
      }
253!
1654
      return 'unknown';
×
1655
    }
×
1656

1657
    if (cellValueType) {
×
1658
      return this.mapCellValueTypeToBasicType(cellValueType);
×
1659
    }
×
1660

1661
    return 'unknown';
×
1662
  }
×
1663

1664
  /**
3,228✔
1665
   * Map cell value types to basic types
1666
   */
3,228✔
1667
  private mapCellValueTypeToBasicType(
3,228✔
1668
    cellValueType: string
253✔
1669
  ): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {
253✔
1670
    switch (cellValueType) {
253✔
1671
      case 'string':
253✔
1672
        return 'string';
63✔
1673
      case 'number':
253✔
1674
        return 'number';
189✔
1675
      case 'boolean':
253✔
1676
        return 'boolean';
1✔
1677
      case 'datetime':
253✔
1678
        return 'datetime';
×
1679
      default:
253✔
1680
        return 'unknown';
×
1681
    }
253✔
1682
  }
253✔
1683

1684
  /**
3,228✔
1685
   * Infer return type from function calls
1686
   */
3,228✔
1687
  // eslint-disable-next-line sonarjs/cognitive-complexity
3,228✔
1688
  private inferFunctionReturnType(
3,228✔
1689
    ctx: FunctionCallContext
1,833✔
1690
  ): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {
1,833✔
1691
    const rawName = ctx.func_name().text.toUpperCase();
1,833✔
1692
    const fnName = normalizeFunctionNameAlias(rawName) as FunctionName;
1,833✔
1693

1694
    if (STRING_FUNCTIONS.has(fnName)) {
1,833✔
1695
      return 'string';
83✔
1696
    }
83✔
1697

1698
    if (NUMBER_FUNCTIONS.has(fnName)) {
1,833✔
1699
      return 'number';
222✔
1700
    }
222✔
1701

1702
    if (BOOLEAN_FUNCTIONS.has(fnName)) {
1,830✔
1703
      return 'boolean';
185✔
1704
    }
185✔
1705

1706
    if (fnName === FunctionName.If) {
1,819✔
1707
      const [, trueExpr, falseExpr] = ctx.expr();
1,018✔
1708
      const trueType = this.inferExpressionType(trueExpr);
1,018✔
1709
      const falseType = this.inferExpressionType(falseExpr);
1,018✔
1710

1711
      if (trueType === falseType) {
1,018✔
1712
        return trueType;
977✔
1713
      }
977✔
1714

1715
      if (trueType === 'number' || falseType === 'number') {
1,018✔
1716
        const trueIsBlank = this.isBlankLikeExpression(trueExpr);
38✔
1717
        const falseIsBlank = this.isBlankLikeExpression(falseExpr);
38✔
1718
        if (trueType === 'number' && (falseIsBlank || falseType === 'number')) {
38✔
1719
          return 'number';
×
1720
        }
×
1721
        if (falseType === 'number' && (trueIsBlank || trueType === 'number')) {
38✔
1722
          return 'number';
×
1723
        }
×
1724
      }
38✔
1725

1726
      if (trueType === 'datetime' && falseType === 'datetime') {
1,018✔
1727
        return 'datetime';
×
1728
      }
✔
1729

1730
      return 'unknown';
41✔
1731
    }
41✔
1732

1733
    if (fnName === FunctionName.Switch) {
1,424✔
1734
      const exprContexts = ctx.expr();
7✔
1735
      const resultExprs: ExprContext[] = [];
7✔
1736

1737
      for (let i = 2; i < exprContexts.length; i += 2) {
7✔
1738
        resultExprs.push(exprContexts[i]);
14✔
1739
      }
14✔
1740

1741
      if (exprContexts.length % 2 === 0 && exprContexts.length > 1) {
7✔
1742
        resultExprs.push(exprContexts[exprContexts.length - 1]);
7✔
1743
      }
7✔
1744

1745
      if (resultExprs.length === 0) {
7!
1746
        return 'unknown';
×
1747
      }
×
1748

1749
      const resultTypes = resultExprs.map((expr) => this.inferExpressionType(expr));
7✔
1750
      const nonUnknownTypes = resultTypes.filter((type) => type !== 'unknown');
7✔
1751

1752
      if (nonUnknownTypes.length === 0) {
7!
1753
        return 'unknown';
×
1754
      }
×
1755

1756
      const firstType = nonUnknownTypes[0];
7✔
1757
      if (nonUnknownTypes.every((type) => type === firstType)) {
7✔
1758
        return firstType;
2✔
1759
      }
2✔
1760

1761
      const hasNumber = nonUnknownTypes.includes('number');
5✔
1762
      const hasDatetime = nonUnknownTypes.includes('datetime');
5✔
1763
      const hasBoolean = nonUnknownTypes.includes('boolean');
5✔
1764

1765
      if (hasNumber) {
5✔
1766
        const convertibleToNumber = resultExprs.every((expr, index) => {
5✔
1767
          const type = resultTypes[index];
5✔
1768
          return type === 'number' || this.isBlankLikeExpression(expr);
5✔
1769
        });
5✔
1770
        if (convertibleToNumber) {
5!
1771
          return 'number';
×
1772
        }
×
1773
      }
5✔
1774

1775
      if (hasDatetime) {
7!
1776
        const convertibleToDatetime = resultExprs.every((expr, index) => {
×
1777
          const type = resultTypes[index];
×
1778
          return type === 'datetime' || this.isBlankLikeExpression(expr);
×
1779
        });
×
1780
        if (convertibleToDatetime) {
×
1781
          return 'datetime';
×
1782
        }
×
1783
      }
✔
1784

1785
      if (hasBoolean) {
7!
1786
        const convertibleToBoolean = resultExprs.every((expr, index) => {
×
1787
          const type = resultTypes[index];
×
1788
          return type === 'boolean' || this.isBlankLikeExpression(expr);
×
1789
        });
×
1790
        if (convertibleToBoolean) {
×
1791
          return 'boolean';
×
1792
        }
×
1793
      }
✔
1794

1795
      return 'unknown';
5✔
1796
    }
5✔
1797

1798
    // Basic detection for functions that yield datetime
318✔
1799
    if (
318✔
1800
      [
318✔
1801
        FunctionName.CreatedTime,
318✔
1802
        FunctionName.LastModifiedTime,
318✔
1803
        FunctionName.Today,
318✔
1804
        FunctionName.Now,
318✔
1805
        FunctionName.DateAdd,
318✔
1806
        FunctionName.DatetimeParse,
318✔
1807
      ].includes(fnName)
318✔
1808
    ) {
1,424✔
1809
      return 'datetime';
145✔
1810
    }
145✔
1811

1812
    return 'unknown';
173✔
1813
  }
173✔
1814

1815
  /**
3,228✔
1816
   * Infer type from binary operations
1817
   */
3,228✔
1818
  private inferBinaryOperationType(
3,228✔
1819
    ctx: BinaryOpContext
1,337✔
1820
  ): 'string' | 'number' | 'boolean' | 'datetime' | 'unknown' {
1,337✔
1821
    const operator = ctx._op?.text;
1,337✔
1822

1823
    if (!operator) {
1,337!
1824
      return 'unknown';
×
1825
    }
×
1826

1827
    const arithmeticOperators = ['-', '*', '/', '%'];
1,337✔
1828
    const comparisonOperators = ['>', '<', '>=', '<=', '=', '!=', '<>', '&&', '||'];
1,337✔
1829
    const stringOperators = ['&']; // Bitwise AND is treated as string concatenation
1,337✔
1830

1831
    // Special handling for + operator - it can be either arithmetic or string concatenation
1,337✔
1832
    if (operator === '+') {
1,337✔
1833
      const leftType = this.inferExpressionType(ctx.expr(0));
67✔
1834
      const rightType = this.inferExpressionType(ctx.expr(1));
67✔
1835

1836
      if (leftType === 'string' || rightType === 'string') {
67!
1837
        return 'string';
×
1838
      }
×
1839

1840
      if (leftType === 'datetime' || rightType === 'datetime') {
67!
1841
        return 'string';
×
1842
      }
×
1843

1844
      return 'number';
67✔
1845
    }
67✔
1846

1847
    if (arithmeticOperators.includes(operator)) {
1,323✔
1848
      return 'number';
109✔
1849
    }
109✔
1850

1851
    if (comparisonOperators.includes(operator)) {
1,303✔
1852
      return 'boolean';
805✔
1853
    }
805✔
1854

1855
    if (stringOperators.includes(operator)) {
356✔
1856
      return 'string';
356✔
1857
    }
356!
1858

1859
    return 'unknown';
×
1860
  }
×
1861
}
3,228✔
1862

1863
/**
2✔
1864
 * Visitor that converts Teable formula AST to SQL expressions for generated columns
1865
 * Uses dependency injection to get database-specific SQL implementations
1866
 * Tracks field dependencies for generated column updates
1867
 */
2✔
1868
export class GeneratedColumnSqlConversionVisitor extends BaseSqlConversionVisitor<IGeneratedColumnQueryInterface> {
2✔
1869
  private dependencies: string[] = [];
408✔
1870

1871
  /**
408✔
1872
   * Get the conversion result with SQL and dependencies
1873
   */
408✔
1874
  getResult(sql: string): IFormulaConversionResult {
408✔
1875
    return {
408✔
1876
      sql,
408✔
1877
      dependencies: Array.from(new Set(this.dependencies)),
408✔
1878
    };
408✔
1879
  }
408✔
1880

1881
  visitFieldReferenceCurly(ctx: FieldReferenceCurlyContext): string {
408✔
1882
    const normalizedFieldId = extractFieldReferenceId(ctx);
392✔
1883
    const rawToken = getFieldReferenceTokenText(ctx);
392✔
1884
    const fieldId = normalizedFieldId ?? rawToken?.slice(1, -1).trim() ?? '';
392!
1885
    this.dependencies.push(fieldId);
392✔
1886
    return super.visitFieldReferenceCurly(ctx);
392✔
1887
  }
392✔
1888
}
408✔
1889

1890
/**
2✔
1891
 * Visitor that converts Teable formula AST to SQL expressions for select queries
1892
 * Uses dependency injection to get database-specific SQL implementations
1893
 * Does not track dependencies as it's used for runtime queries
1894
 */
2✔
1895
export class SelectColumnSqlConversionVisitor extends BaseSqlConversionVisitor<ISelectQueryInterface> {
2✔
1896
  /**
2✔
1897
   * Override field reference handling to support CTE-based field references
1898
   */
2✔
1899
  // eslint-disable-next-line sonarjs/cognitive-complexity
2✔
1900
  visitFieldReferenceCurly(ctx: FieldReferenceCurlyContext): string {
2✔
1901
    const normalizedFieldId = extractFieldReferenceId(ctx);
3,421✔
1902
    const rawToken = getFieldReferenceTokenText(ctx);
3,421✔
1903
    const fieldId = normalizedFieldId ?? rawToken?.slice(1, -1).trim() ?? '';
3,421!
1904

1905
    const fieldInfo = this.context.table.getField(fieldId);
3,421✔
1906
    if (!fieldInfo) {
3,421!
1907
      // Fallback: referenced field not found in current table domain.
×
1908
      // Return NULL and emit a warning for visibility without breaking the query.
×
1909
      try {
×
1910
        const t = this.context.table;
×
1911
        // eslint-disable-next-line no-console
×
1912
        console.warn(
×
1913
          `Select formula fallback: missing field {${fieldId}} in table ${t?.name || ''}(${t?.id || ''}); selecting NULL`
×
1914
        );
1915
      } catch {
×
1916
        // ignore logging failures
×
1917
      }
×
1918
      return 'NULL';
×
1919
    }
×
1920

1921
    // Check if this field has a CTE mapping (for link, lookup, rollup fields)
3,421✔
1922
    const selectContext = this.context as ISelectFormulaConversionContext;
3,421✔
1923
    const preferRaw = !!selectContext.preferRawFieldReferences;
3,421✔
1924
    const selectionMap = selectContext.selectionMap;
3,421✔
1925
    const selection = selectionMap?.get(fieldId);
3,421✔
1926
    let selectionSql = typeof selection === 'string' ? selection : selection?.toSQL().sql;
3,421✔
1927
    const cteMap = selectContext.fieldCteMap;
3,421✔
1928
    const readyLinkFieldIds =
3,421✔
1929
      selectContext.readyLinkFieldIds &&
3,421✔
1930
      typeof (selectContext.readyLinkFieldIds as { has?: unknown }).has === 'function'
71✔
1931
        ? (selectContext.readyLinkFieldIds as ReadonlySet<string>)
3,421✔
1932
        : undefined;
3,350✔
1933
    const isSelfReference = selectContext.currentLinkFieldId === fieldId;
3,421✔
1934
    // For link fields with CTE mapping, use the CTE directly
3,421✔
1935
    // No need for complex cross-CTE reference handling in most cases
3,421✔
1936

1937
    // Handle different field types that use CTEs
3,421✔
1938
    if (isLinkField(fieldInfo)) {
3,421✔
1939
      // Prefer direct column when raw references are requested; otherwise fallback to CTE mapping.
99✔
1940
      // However, when the field is not already part of the current selection (common when resolving
99✔
1941
      // display fields for nested link CTEs), we still need to reference the CTE to access the link
99✔
1942
      // value even in raw contexts; otherwise formulas that reference link fields end up reading
99✔
1943
      // NULL placeholders instead of the computed JSON payload.
99✔
1944
      const canReferenceCte =
99✔
1945
        !preferRaw && !isSelfReference && readyLinkFieldIds?.has(fieldId) && cteMap?.has(fieldId);
99✔
1946
      if (canReferenceCte) {
99!
1947
        const cteName = cteMap!.get(fieldId)!;
×
1948
        selectionSql = `"${cteName}"."link_value"`;
×
1949
      }
×
1950
      // Provide a safe fallback if selection map has no entry
99✔
1951
      if (!selectionSql) {
99✔
1952
        if (selectContext.tableAlias) {
36✔
1953
          selectionSql = `"${selectContext.tableAlias}"."${fieldInfo.dbFieldName}"`;
36✔
1954
        } else {
36!
1955
          selectionSql = `"${fieldInfo.dbFieldName}"`;
×
1956
        }
×
1957
      }
36✔
1958
      // Check if this link field is being used in a boolean context
99✔
1959
      const isBooleanContext = this.isInBooleanContext(ctx);
99✔
1960

1961
      // Use database driver from context
99✔
1962
      if (isBooleanContext) {
99✔
1963
        return this.dialect!.linkHasAny(selectionSql);
9✔
1964
      }
9✔
1965
      // For non-boolean context, extract title values as JSON array or single title
90✔
1966
      return this.dialect!.linkExtractTitles(selectionSql, !!fieldInfo.isMultipleCellValue);
90✔
1967
    }
90✔
1968

1969
    // Check if this is a formula field that needs recursive expansion
3,322✔
1970
    if (shouldExpandFieldReference(fieldInfo)) {
3,413✔
1971
      return this.expandFormulaField(fieldId, fieldInfo);
98✔
1972
    }
98✔
1973

1974
    // If this is a lookup or rollup and CTE map is available, use it
3,224✔
1975
    const linkLookupOptions =
3,224✔
1976
      fieldInfo.lookupOptions && isLinkLookupOptions(fieldInfo.lookupOptions)
3,413✔
1977
        ? fieldInfo.lookupOptions
364✔
1978
        : undefined;
2,860✔
1979
    if (cteMap && linkLookupOptions && cteMap.has(linkLookupOptions.linkFieldId)) {
3,421✔
1980
      const cteName = cteMap.get(linkLookupOptions.linkFieldId)!;
360✔
1981
      const columnName = fieldInfo.isLookup
360✔
1982
        ? `lookup_${fieldInfo.id}`
313✔
1983
        : (fieldInfo as unknown as { type?: string }).type === 'rollup'
47✔
1984
          ? `rollup_${fieldInfo.id}`
47✔
1985
          : undefined;
×
1986
      if (columnName) {
360✔
1987
        let columnRef = `"${cteName}"."${columnName}"`;
360✔
1988
        if (preferRaw && fieldInfo.type !== FieldType.Link) {
360✔
1989
          const adjusted = this.coerceRawMultiValueReference(columnRef, fieldInfo, selectContext);
336✔
1990
          if (selectContext.targetDbFieldType === DbFieldType.Json) {
336✔
1991
            return adjusted;
34✔
1992
          }
34✔
1993
          columnRef = adjusted;
302✔
1994
        }
302✔
1995
        if (
326✔
1996
          fieldInfo.type === FieldType.Link &&
326✔
1997
          fieldInfo.isLookup &&
360✔
1998
          isLinkLookupOptions(fieldInfo.lookupOptions)
3✔
1999
        ) {
360✔
2000
          if (preferRaw && selectContext.targetDbFieldType === DbFieldType.Json) {
3!
2001
            return columnRef;
×
2002
          }
×
2003
          if (fieldInfo.dbFieldType !== DbFieldType.Json) {
3!
2004
            return columnRef;
×
2005
          }
×
2006
          const titlesExpr = this.dialect!.linkExtractTitles(
3✔
2007
            columnRef,
3✔
2008
            !!fieldInfo.isMultipleCellValue
3✔
2009
          );
2010
          if (fieldInfo.isMultipleCellValue) {
3✔
2011
            return this.dialect!.formatStringArray(titlesExpr, { fieldInfo });
3✔
2012
          }
3!
2013
          return titlesExpr;
×
2014
        }
✔
2015
        return columnRef;
323✔
2016
      }
323✔
2017
    }
360✔
2018

2019
    // Handle user-related fields
2,864✔
2020
    if (fieldInfo.type === FieldType.CreatedBy || fieldInfo.type === FieldType.LastModifiedBy) {
3,421✔
2021
      // For system user fields, derive directly from system columns to avoid JSON dependency
2✔
2022
      const alias = selectContext.tableAlias;
2✔
2023
      const sysCol = fieldInfo.type === FieldType.CreatedBy ? '__created_by' : '__last_modified_by';
2!
2024
      const idRef = alias ? `"${alias}"."${sysCol}"` : `"${sysCol}"`;
2!
2025
      return this.dialect!.selectUserNameById(idRef);
2✔
2026
    }
2✔
2027
    if (fieldInfo.type === FieldType.User) {
3,394✔
2028
      // For normal User fields, extract title from the JSON selection when available
6✔
2029
      if (!selectionSql) {
6!
2030
        if (selectContext.tableAlias) {
×
2031
          selectionSql = `"${selectContext.tableAlias}"."${fieldInfo.dbFieldName}"`;
×
2032
        } else {
×
2033
          selectionSql = `"${fieldInfo.dbFieldName}"`;
×
2034
        }
×
2035
      }
×
2036

2037
      if (preferRaw && selectContext.targetDbFieldType === DbFieldType.Json) {
6✔
2038
        if (fieldInfo.isMultipleCellValue) {
2✔
2039
          return this.dialect!.linkExtractTitles(selectionSql, true);
2✔
2040
        }
2!
2041
        // For single-value formulas targeting json columns, wrap scalar title as json
×
2042
        const titleExpr = this.dialect!.jsonTitleFromExpr(selectionSql);
×
2043
        if (this.dialect!.driver === DriverClient.Pg) {
×
2044
          return `to_jsonb(${titleExpr})`;
×
2045
        }
×
2046
        if (this.dialect!.driver === DriverClient.Sqlite) {
×
2047
          return `json(${titleExpr})`;
×
2048
        }
×
2049
        return titleExpr;
×
2050
      }
✔
2051

2052
      return this.dialect!.jsonTitleFromExpr(selectionSql);
4✔
2053
    }
4✔
2054

2055
    if (selectionSql) {
3,113✔
2056
      const normalizedSelection = this.normalizeLookupSelection(
2,785✔
2057
        selectionSql,
2,785✔
2058
        fieldInfo,
2,785✔
2059
        selectContext
2,785✔
2060
      );
2061

2062
      if (normalizedSelection !== selectionSql) {
2,785!
2063
        return normalizedSelection;
×
2064
      }
×
2065

2066
      if (preferRaw) {
2,785✔
2067
        return this.coerceRawMultiValueReference(selectionSql, fieldInfo, selectContext);
2,785✔
2068
      }
2,785✔
2069

2070
      return selectionSql;
×
2071
    }
✔
2072
    // Use table alias if provided in context
71✔
2073
    if (selectContext.tableAlias) {
71✔
2074
      const aliasExpr = `"${selectContext.tableAlias}"."${fieldInfo.dbFieldName}"`;
71✔
2075
      return preferRaw
71✔
2076
        ? this.coerceRawMultiValueReference(aliasExpr, fieldInfo, selectContext)
19✔
2077
        : aliasExpr;
52✔
2078
    }
71!
2079

2080
    const fallbackExpr = this.formulaQuery.fieldReference(fieldId, fieldInfo.dbFieldName);
×
2081
    return preferRaw
×
2082
      ? this.coerceRawMultiValueReference(fallbackExpr, fieldInfo, selectContext)
×
2083
      : fallbackExpr;
×
2084
  }
3,421✔
2085

2086
  private normalizeLookupSelection(
2✔
2087
    expr: string,
2,785✔
2088
    fieldInfo: FieldCore,
2,785✔
2089
    selectContext: ISelectFormulaConversionContext
2,785✔
2090
  ): string {
2,785✔
2091
    if (!expr) {
2,785!
2092
      return expr;
×
2093
    }
×
2094

2095
    const dialect = this.dialect;
2,785✔
2096
    if (!dialect) {
2,785!
2097
      return expr;
×
2098
    }
×
2099

2100
    if (
2,785✔
2101
      fieldInfo.type !== FieldType.Link ||
2,785!
2102
      !fieldInfo.isLookup ||
2,785!
2103
      !fieldInfo.lookupOptions ||
2,785!
2104
      !isLinkLookupOptions(fieldInfo.lookupOptions)
×
2105
    ) {
2,785✔
2106
      return expr;
2,785✔
2107
    }
2,785!
2108

2109
    const preferRaw = !!selectContext.preferRawFieldReferences;
×
2110
    const targetDbType = selectContext.targetDbFieldType;
×
2111
    if (preferRaw && targetDbType === DbFieldType.Json) {
2,785!
2112
      return expr;
×
2113
    }
×
2114

2115
    const trimmed = expr.trim();
×
2116
    if (!trimmed || trimmed.toUpperCase() === 'NULL') {
2,785!
2117
      return expr;
×
2118
    }
×
2119

2120
    if (fieldInfo.dbFieldType !== DbFieldType.Json) {
×
2121
      return expr;
×
2122
    }
×
2123

2124
    const titlesExpr = dialect.linkExtractTitles(expr, !!fieldInfo.isMultipleCellValue);
×
2125
    if (fieldInfo.isMultipleCellValue) {
×
NEW
2126
      return dialect.formatStringArray(titlesExpr, { fieldInfo });
×
2127
    }
×
2128
    return titlesExpr;
×
2129
  }
×
2130

2131
  private coerceRawMultiValueReference(
2✔
2132
    expr: string,
3,140✔
2133
    fieldInfo: FieldCore,
3,140✔
2134
    selectContext: ISelectFormulaConversionContext
3,140✔
2135
  ): string {
3,140✔
2136
    if (!expr) return expr;
3,140!
2137
    const trimmed = expr.trim().toUpperCase();
3,140✔
2138
    if (trimmed === 'NULL') {
3,140!
2139
      return expr;
×
2140
    }
×
2141
    if (!fieldInfo.isMultipleCellValue) {
3,140✔
2142
      return expr;
3,012✔
2143
    }
3,012✔
2144

2145
    const targetType = selectContext.targetDbFieldType;
128✔
2146
    if (!targetType || targetType === DbFieldType.Json) {
3,140✔
2147
      return expr;
44✔
2148
    }
44✔
2149

2150
    if (!this.dialect) {
1,660!
2151
      return expr;
×
2152
    }
✔
2153

2154
    // eslint-disable-next-line sonarjs/no-small-switch
84✔
2155
    switch (this.dialect.driver) {
84✔
2156
      case DriverClient.Pg: {
84✔
2157
        if (targetType !== DbFieldType.DateTime) {
84✔
2158
          return expr;
78✔
2159
        }
78✔
2160
        const safeJsonExpr = `(CASE
6✔
2161
          WHEN pg_typeof(${expr}) = 'jsonb'::regtype THEN (${expr})::text::jsonb
6✔
2162
          WHEN pg_typeof(${expr}) = 'json'::regtype THEN (${expr})::text::jsonb
6✔
2163
          ELSE NULL::jsonb
2164
        END)`;
6✔
2165
        return `(SELECT elem #>> '{}'
6✔
2166
          FROM jsonb_array_elements(COALESCE(${safeJsonExpr}, '[]'::jsonb)) AS elem
6✔
2167
          WHERE jsonb_typeof(elem) NOT IN ('array','object')
2168
          LIMIT 1
2169
        )`;
6✔
2170
      }
6✔
2171
      default:
3,140!
2172
        return expr;
×
2173
    }
3,140✔
2174
  }
3,140✔
2175

2176
  /**
2✔
2177
   * Check if a field reference is being used in a boolean context
2178
   * (i.e., as a parameter to logical functions like AND, OR, NOT, etc.)
2179
   */
2✔
2180
  private isInBooleanContext(ctx: FieldReferenceCurlyContext): boolean {
2✔
2181
    let parent = ctx.parent;
99✔
2182

2183
    // Walk up the parse tree to find if we're inside a logical function
99✔
2184
    while (parent) {
99✔
2185
      if (parent instanceof FunctionCallContext) {
102✔
2186
        const rawName = parent.func_name().text.toUpperCase();
32✔
2187
        const fnName = normalizeFunctionNameAlias(rawName) as FunctionName;
32✔
2188
        return BOOLEAN_FUNCTIONS.has(fnName) || fnName === FunctionName.If;
32✔
2189
      }
32✔
2190

2191
      // Also check for binary logical operators
70✔
2192
      if (parent instanceof BinaryOpContext) {
75✔
2193
        const operator = parent._op?.text;
3✔
2194
        if (!operator) return false;
3!
2195
        // Only treat actual logical operators as boolean context; comparison operators
3✔
2196
        // should preserve the original field value for proper type-aware comparisons.
3✔
2197
        const logicalOperators = ['&&', '||'];
3✔
2198
        return logicalOperators.includes(operator);
3✔
2199
      }
3✔
2200

2201
      parent = parent.parent;
67✔
2202
    }
67✔
2203

2204
    return false;
64✔
2205
  }
64✔
2206
}
2✔
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

© 2026 Coveralls, Inc