Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 4e165722af fix: return impossible filter instead of undefined when filter value is empty
https://sonarly.com/issue/16043?type=bug

When a FIND_RECORDS workflow step references a variable that resolves to null/empty (e.g., from a previous step that returned no results), the filter condition is silently removed from the query, causing the step to match all records satisfying only the remaining conditions.

Fix: **Problem:** When `turnRecordFilterIntoRecordGqlOperationFilter` encounters an empty parsed value (e.g., empty `selectedRecordIds` for RELATION, empty `options` for SELECT/MULTI_SELECT, empty `recordIds` for UUID), it returns `undefined`. The caller uses `.filter(isDefined)` to remove undefined results, which silently drops the filter condition from the AND/OR group. In workflow contexts where variables resolve to null/empty, this causes FIND_RECORDS to match all records instead of zero.

**Fix:** Created `getMatchNothingRecordGqlOperationFilter()` utility that returns `{and: [{id: {is: 'NULL'}}, {id: {is: 'NOT_NULL'}}]}` — a contradictory filter guaranteed to match zero records. Replaced `return;` / `return undefined` with `return getMatchNothingRecordGqlOperationFilter();` at all 4 affected locations:

1. **RELATION** (line 537): `if (!isDefined(recordIds) || recordIds.length === 0)`
2. **MULTI_SELECT** (line 971): `if (options.length === 0)`
3. **SELECT** (line 1029): `if (options.length === 0)`
4. **UUID** (line 1497): `if (!isDefined(recordIds) || recordIds.length === 0)`

Also removed the redundant duplicate empty check in the RELATION IS_NOT case (previously line 545) since it's already guarded by the check at line 537.

Added 5 test cases covering all affected filter types with empty values, verifying they produce the match-nothing filter rather than being silently dropped.
2026-03-18 17:24:13 +00:00
4 changed files with 197 additions and 5 deletions
@@ -5,6 +5,10 @@ import { FieldMetadataType } from '@/types/FieldMetadataType';
import type { PartialFieldMetadataItem } from '@/types/PartialFieldMetadataItem';
import { ViewFilterOperand } from '@/types/ViewFilterOperand';
const matchNothingFilter = {
and: [{ id: { is: 'NULL' } }, { id: { is: 'NOT_NULL' } }],
};
describe('computeRecordGqlOperationFilter', () => {
it('should match Is UUID', () => {
const companyIdField: PartialFieldMetadataItem = {
@@ -41,4 +45,179 @@ describe('computeRecordGqlOperationFilter', () => {
},
});
});
describe('should return match-nothing filter instead of dropping empty filter conditions', () => {
it('should not drop RELATION filter when selectedRecordIds is empty', () => {
const retreatField: PartialFieldMetadataItem = {
id: 'retreat-field',
name: 'retreat',
label: 'Retreat',
type: FieldMetadataType.RELATION,
};
const statusField: PartialFieldMetadataItem = {
id: 'status-field',
name: 'status',
label: 'Status',
type: FieldMetadataType.SELECT,
};
const recordFilters: RecordFilter[] = [
{
id: 'relation-filter',
fieldMetadataId: retreatField.id,
value: JSON.stringify({
isCurrentWorkspaceMemberSelected: false,
selectedRecordIds: [],
}),
type: 'RELATION',
operand: ViewFilterOperand.IS,
},
{
id: 'status-filter',
fieldMetadataId: statusField.id,
value: JSON.stringify(['PAID']),
type: 'SELECT',
operand: ViewFilterOperand.IS,
},
];
const filter = computeRecordGqlOperationFilter({
fields: [retreatField, statusField],
recordFilters,
recordFilterGroups: [],
filterValueDependencies: {
timeZone: 'UTC',
},
});
expect(filter).toEqual({
and: [
matchNothingFilter,
{ status: { in: ['PAID'] } },
],
});
});
it('should not drop RELATION filter when value is an invalid UUID', () => {
const retreatField: PartialFieldMetadataItem = {
id: 'retreat-field',
name: 'retreat',
label: 'Retreat',
type: FieldMetadataType.RELATION,
};
const recordFilters: RecordFilter[] = [
{
id: 'relation-filter',
fieldMetadataId: retreatField.id,
value: '',
type: 'RELATION',
operand: ViewFilterOperand.IS,
},
];
const filter = computeRecordGqlOperationFilter({
fields: [retreatField],
recordFilters,
recordFilterGroups: [],
filterValueDependencies: {
timeZone: 'UTC',
},
});
// Empty string value should be skipped by checkIfShouldSkipFiltering
expect(filter).toEqual({});
});
it('should not drop UUID filter when value resolves to empty', () => {
const idField: PartialFieldMetadataItem = {
id: 'id-field',
name: 'id',
label: 'ID',
type: FieldMetadataType.UUID,
};
const recordFilters: RecordFilter[] = [
{
id: 'uuid-filter',
fieldMetadataId: idField.id,
value: 'not-a-valid-uuid',
type: 'UUID',
operand: ViewFilterOperand.IS,
},
];
const filter = computeRecordGqlOperationFilter({
fields: [idField],
recordFilters,
recordFilterGroups: [],
filterValueDependencies: {
timeZone: 'UTC',
},
});
expect(filter).toEqual(matchNothingFilter);
});
it('should not drop SELECT filter when options resolve to empty', () => {
const statusField: PartialFieldMetadataItem = {
id: 'status-field',
name: 'status',
label: 'Status',
type: FieldMetadataType.SELECT,
};
const recordFilters: RecordFilter[] = [
{
id: 'select-filter',
fieldMetadataId: statusField.id,
value: JSON.stringify([]),
type: 'SELECT',
operand: ViewFilterOperand.IS,
},
];
const filter = computeRecordGqlOperationFilter({
fields: [statusField],
recordFilters,
recordFilterGroups: [],
filterValueDependencies: {
timeZone: 'UTC',
},
});
expect(filter).toEqual(matchNothingFilter);
});
it('should not drop MULTI_SELECT filter when options resolve to empty', () => {
const tagsField: PartialFieldMetadataItem = {
id: 'tags-field',
name: 'tags',
label: 'Tags',
type: FieldMetadataType.MULTI_SELECT,
};
const recordFilters: RecordFilter[] = [
{
id: 'multiselect-filter',
fieldMetadataId: tagsField.id,
value: JSON.stringify([]),
type: 'MULTI_SELECT',
operand: ViewFilterOperand.CONTAINS,
},
];
const filter = computeRecordGqlOperationFilter({
fields: [tagsField],
recordFilters,
recordFilterGroups: [],
filterValueDependencies: {
timeZone: 'UTC',
},
});
expect(filter).toEqual(matchNothingFilter);
});
});
});
@@ -13,6 +13,7 @@ export * from './utils/combineFilters';
export * from './utils/fieldRatingConvertors';
export * from './utils/generateILikeFiltersForCompositeFields';
export * from './utils/getEmptyRecordGqlOperationFilter';
export * from './utils/getMatchNothingRecordGqlOperationFilter';
export * from './utils/getFilterTypeFromFieldType';
export * from './utils/isExpectedSubFieldName';
export * from './utils/isMatchingArrayFilter';
@@ -53,6 +53,7 @@ import {
} from '@/utils';
import { arrayOfStringsOrVariablesSchema } from '@/utils/filter/utils/validation-schemas/arrayOfStringsOrVariablesSchema';
import { arrayOfUuidOrVariableSchema } from '@/utils/filter/utils/validation-schemas/arrayOfUuidsOrVariablesSchema';
import { getMatchNothingRecordGqlOperationFilter } from '@/utils/filter/utils/getMatchNothingRecordGqlOperationFilter';
import { jsonRelationFilterValueSchema } from '@/utils/filter/utils/validation-schemas/jsonRelationFilterValueSchema';
type FieldShared = {
@@ -532,7 +533,8 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
]
: selectedRecordIds;
if (!isDefined(recordIds) || recordIds.length === 0) return;
if (!isDefined(recordIds) || recordIds.length === 0)
return getMatchNothingRecordGqlOperationFilter();
switch (recordFilter.operand) {
case RecordFilterOperand.IS:
@@ -542,7 +544,6 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
} as RelationFilter,
};
case RecordFilterOperand.IS_NOT: {
if (!isDefined(recordIds) || recordIds.length === 0) return;
return {
or: [
{
@@ -967,7 +968,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
case 'MULTI_SELECT': {
const options = arrayOfStringsOrVariablesSchema.parse(recordFilter.value);
if (options.length === 0) return;
if (options.length === 0) return getMatchNothingRecordGqlOperationFilter();
const emptyOptions = options.filter((option: string) => option === '');
const nonEmptyOptions = options.filter((option: string) => option !== '');
@@ -1025,7 +1026,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
case 'SELECT': {
const options = arrayOfStringsOrVariablesSchema.parse(recordFilter.value);
if (options.length === 0) return;
if (options.length === 0) return getMatchNothingRecordGqlOperationFilter();
const emptyOptions = options.filter((option: string) => option === '');
const nonEmptyOptions = options.filter((option: string) => option !== '');
@@ -1492,7 +1493,8 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
case 'UUID': {
const recordIds = arrayOfUuidOrVariableSchema.parse(recordFilter.value);
if (!isDefined(recordIds) || recordIds.length === 0) return;
if (!isDefined(recordIds) || recordIds.length === 0)
return getMatchNothingRecordGqlOperationFilter();
switch (recordFilter.operand) {
case RecordFilterOperand.IS:
@@ -0,0 +1,10 @@
import { type RecordGqlOperationFilter } from '@/types';
// Returns a filter guaranteed to match zero records.
// Used when a filter value resolves to empty (e.g., workflow variable
// referencing a previous step that returned no results), so the filter
// condition is preserved rather than silently dropped from the query.
export const getMatchNothingRecordGqlOperationFilter =
(): RecordGqlOperationFilter => ({
and: [{ id: { is: 'NULL' } }, { id: { is: 'NOT_NULL' } }],
});