fix: prevent FIND_RECORDS from silently dropping unresolved filter variables (#18814)
## Summary Fixes #18744 — The workflow FIND_RECORDS action silently drops filter conditions when a variable resolves to null/empty, causing the query to return **all records** instead of erroring. **Root cause (three compounding layers):** 1. **`variable-resolver.ts`** — `resolveString` returns `undefined` when a variable lookup fails (e.g., `{{steps.trigger.output.userId}}` where `userId` doesn't exist in context). The return type says `string` but `evalFromContext` actually returns `undefined` at runtime. 2. **`checkIfShouldSkipFiltering.ts`** — Treats `undefined`/`null`/`""` values as "skip this filter." This is correct for the **UI filter builder** (user hasn't finished typing), but wrong for **workflow execution** (variable resolution failed = misconfigured workflow). 3. **`find-records.workflow-action.ts`** — When all filters are silently skipped, `computeRecordGqlOperationFilter` returns `{}` (match everything). The query runs with no filter, returning all records — silently succeeding with wrong results. ## Fix Added validation in `find-records.workflow-action.ts` **after** `resolveInput` but **before** `computeRecordGqlOperationFilter`. For each filter with a value-requiring operand (i.e., not IS_EMPTY, IS_NOT_EMPTY, IS_IN_PAST, IS_IN_FUTURE, IS_TODAY), if the resolved value is `undefined`, `null`, or `""`, throw `INVALID_STEP_INPUT` with a descriptive error message. **Why this approach:** - Scoped to the workflow executor — does **not** break the UI filter builder's intentional skip-on-empty behavior - Does not change shared utilities (`checkIfShouldSkipFiltering`, `resolveInput`) used across the app - Fails fast with a clear error instead of silently returning wrong data - 1 file changed, 23 lines added ## Test plan - [x] Backend typecheck passes - [x] oxlint passes (0 warnings, 0 errors) - [x] Prettier passes - [ ] Manual: Create a workflow with FIND_RECORDS using a variable that doesn't exist → should error with "Filter condition has an empty value after variable resolution" instead of returning all records - [ ] Manual: Create a workflow with FIND_RECORDS using IS_EMPTY operand (no value needed) → should still work correctly --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
+3
-3
@@ -1,8 +1,8 @@
|
||||
import { ViewFilterOperand } from 'twenty-shared/types';
|
||||
|
||||
import { isFilterOperandExpectingValue } from '@/object-record/object-filter-dropdown/utils/isFilterOperandExpectingValue';
|
||||
import { isRecordFilterOperandExpectingValue } from 'twenty-shared/utils';
|
||||
|
||||
describe('isFilterOperandExpectingValue', () => {
|
||||
describe('isRecordFilterOperandExpectingValue', () => {
|
||||
const testCases = [
|
||||
{ operand: ViewFilterOperand.CONTAINS, expectedResult: true },
|
||||
{ operand: ViewFilterOperand.DOES_NOT_CONTAIN, expectedResult: true },
|
||||
@@ -24,7 +24,7 @@ describe('isFilterOperandExpectingValue', () => {
|
||||
|
||||
testCases.forEach(({ operand, expectedResult }) => {
|
||||
it(`should return ${expectedResult} for ViewFilterOperand.${operand}`, () => {
|
||||
expect(isFilterOperandExpectingValue(operand)).toBe(expectedResult);
|
||||
expect(isRecordFilterOperandExpectingValue(operand)).toBe(expectedResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
import { ViewFilterOperand } from 'twenty-shared/types';
|
||||
|
||||
export const isFilterOperandExpectingValue = (operand: ViewFilterOperand) => {
|
||||
switch (operand) {
|
||||
case ViewFilterOperand.IS_NOT_NULL:
|
||||
case ViewFilterOperand.IS_EMPTY:
|
||||
case ViewFilterOperand.IS_NOT_EMPTY:
|
||||
case ViewFilterOperand.IS_IN_PAST:
|
||||
case ViewFilterOperand.IS_IN_FUTURE:
|
||||
case ViewFilterOperand.IS_TODAY:
|
||||
return false;
|
||||
case ViewFilterOperand.IS_NOT:
|
||||
case ViewFilterOperand.CONTAINS:
|
||||
case ViewFilterOperand.DOES_NOT_CONTAIN:
|
||||
case ViewFilterOperand.GREATER_THAN_OR_EQUAL:
|
||||
case ViewFilterOperand.LESS_THAN_OR_EQUAL:
|
||||
case ViewFilterOperand.IS_BEFORE:
|
||||
case ViewFilterOperand.IS_AFTER:
|
||||
case ViewFilterOperand.IS:
|
||||
case ViewFilterOperand.IS_RELATIVE:
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
+2
-18
@@ -1,24 +1,8 @@
|
||||
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isRecordFilterValueValid } from 'twenty-shared/utils';
|
||||
|
||||
export const isRecordFilterConsideredEmpty = (
|
||||
recordFilter: RecordFilter,
|
||||
): boolean => {
|
||||
const { value, operand } = recordFilter;
|
||||
|
||||
if (
|
||||
(!isDefined(value) || value === '' || value === '[]') &&
|
||||
![
|
||||
RecordFilterOperand.IS_EMPTY,
|
||||
RecordFilterOperand.IS_NOT_EMPTY,
|
||||
RecordFilterOperand.IS_IN_PAST,
|
||||
RecordFilterOperand.IS_IN_FUTURE,
|
||||
RecordFilterOperand.IS_TODAY,
|
||||
].includes(operand)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return !isRecordFilterValueValid(recordFilter);
|
||||
};
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import type { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
|
||||
import {
|
||||
RowLevelPermissionPredicateOperand,
|
||||
ViewFilterOperand,
|
||||
} from 'twenty-shared/types';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
export const mapRLSOperandToRecordFilterOperand = (
|
||||
operand: RowLevelPermissionPredicateOperand,
|
||||
): RecordFilterOperand => {
|
||||
switch (operand) {
|
||||
case RowLevelPermissionPredicateOperand.IS:
|
||||
return ViewFilterOperand.IS;
|
||||
case RowLevelPermissionPredicateOperand.IS_NOT_NULL:
|
||||
return ViewFilterOperand.IS_NOT_NULL;
|
||||
case RowLevelPermissionPredicateOperand.IS_NOT:
|
||||
return ViewFilterOperand.IS_NOT;
|
||||
case RowLevelPermissionPredicateOperand.LESS_THAN_OR_EQUAL:
|
||||
return ViewFilterOperand.LESS_THAN_OR_EQUAL;
|
||||
case RowLevelPermissionPredicateOperand.GREATER_THAN_OR_EQUAL:
|
||||
return ViewFilterOperand.GREATER_THAN_OR_EQUAL;
|
||||
case RowLevelPermissionPredicateOperand.IS_BEFORE:
|
||||
return ViewFilterOperand.IS_BEFORE;
|
||||
case RowLevelPermissionPredicateOperand.IS_AFTER:
|
||||
return ViewFilterOperand.IS_AFTER;
|
||||
case RowLevelPermissionPredicateOperand.CONTAINS:
|
||||
return ViewFilterOperand.CONTAINS;
|
||||
case RowLevelPermissionPredicateOperand.DOES_NOT_CONTAIN:
|
||||
return ViewFilterOperand.DOES_NOT_CONTAIN;
|
||||
case RowLevelPermissionPredicateOperand.IS_EMPTY:
|
||||
return ViewFilterOperand.IS_EMPTY;
|
||||
case RowLevelPermissionPredicateOperand.IS_NOT_EMPTY:
|
||||
return ViewFilterOperand.IS_NOT_EMPTY;
|
||||
case RowLevelPermissionPredicateOperand.IS_RELATIVE:
|
||||
return ViewFilterOperand.IS_RELATIVE;
|
||||
case RowLevelPermissionPredicateOperand.IS_IN_PAST:
|
||||
return ViewFilterOperand.IS_IN_PAST;
|
||||
case RowLevelPermissionPredicateOperand.IS_IN_FUTURE:
|
||||
return ViewFilterOperand.IS_IN_FUTURE;
|
||||
case RowLevelPermissionPredicateOperand.IS_TODAY:
|
||||
return ViewFilterOperand.IS_TODAY;
|
||||
case RowLevelPermissionPredicateOperand.VECTOR_SEARCH:
|
||||
return ViewFilterOperand.VECTOR_SEARCH;
|
||||
default:
|
||||
assertUnreachable(operand);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user