Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
orderBy: args.orderBy,
});

if (isDefined(args.offset)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,32 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';

import { type ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';

import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { pascalCase } from 'src/utils/pascal-case';

export const buildColumnsToSelect = ({
select,
relations,
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
orderBy,
}: {
select: Record<string, unknown>;
relations: Record<string, unknown>;
flatObjectMetadata: FlatObjectMetadata;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
orderBy?: ObjectRecordOrderBy;
}) => {
const requiredRelationColumns = getRequiredRelationColumns(
relations,
Expand All @@ -39,6 +45,16 @@ export const buildColumnsToSelect = ({
fieldsToSelect[columnName] = true;
}

const orderByColumnNames = extractColumnNamesFromOrderBy(
orderBy,
flatObjectMetadata,
flatFieldMetadataMaps,
);

for (const columnName of orderByColumnNames) {
fieldsToSelect[columnName] = true;
}

return { ...fieldsToSelect, id: true };
};

Expand Down Expand Up @@ -101,3 +117,56 @@ const getRequiredRelationColumns = (

return requiredColumns;
};

const extractColumnNamesFromOrderBy = (
orderBy: ObjectRecordOrderBy | undefined,
flatObjectMetadata: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
) => {
if (!isDefined(orderBy) || orderBy.length === 0) {
return [];
}

const orderByFlattened = orderBy.reduce(
(acc, orderByItem) => ({ ...acc, ...orderByItem }),
{},
);

const { fieldIdByName } = buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
flatObjectMetadata,
);

const columnNames = [];

for (const [fieldName, orderByValue] of Object.entries(orderByFlattened)) {
const fieldMetadataId = fieldIdByName[fieldName];

if (!fieldMetadataId) {
columnNames.push(fieldName);
continue;
}

const fieldMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: fieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});

if (
isCompositeFieldMetadataType(fieldMetadata.type) &&
isDefined(orderByValue)
) {
const subFieldNames = Object.keys(orderByValue);

for (const subFieldName of subFieldNames) {
const columnName = `${fieldName}${pascalCase(subFieldName)}`;

columnNames.push(columnName);
}
} else {
columnNames.push(fieldName);
}
}

return columnNames;
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { isNull } from '@sniptt/guards';
import {
FieldMetadataType,
type ObjectRecord,
OrderByDirection,
compositeTypeDefinitions,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
Expand Down Expand Up @@ -29,6 +31,87 @@ type BuildCursorCompositeFieldWhereConditionParams = {
isEqualityCondition?: boolean;
};

const buildNullCompositeFieldFilter = (
fieldKey: keyof ObjectRecord,
compositeFieldProperties: Array<{ name: string }>,
): ObjectRecordFilter => {
const nullFilters = compositeFieldProperties.reduce<
Record<string, ObjectRecordFilter>
>(
(acc, property) => ({
...acc,
[property.name]: { is: 'NULL' },
}),
{},
);

return { [fieldKey]: nullFilters };
};

const buildAllNullComparisonFilter = (
fieldKey: keyof ObjectRecord,
firstProperty: { name: string },
computedOperator: string,
): ObjectRecordFilter | Record<string, never> => {
if (computedOperator === 'gt') {
return {
[fieldKey]: {
[firstProperty.name]: { is: 'NOT NULL' },
},
};
}
Comment on lines +57 to +63
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The gt operator case doesn't check null ordering and always returns NOT NULL. For NULLS LAST ordering with forward pagination from a null cursor, there are no records after null, so it should return {} (empty filter). Currently it returns NOT NULL, which would incorrectly fetch records that appear before the null cursor.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/twenty-server/src/engine/api/utils/build-cursor-composite-field-where-condition.utils.ts, line 57:

<comment>The `gt` operator case doesn&#39;t check null ordering and always returns `NOT NULL`. For NULLS LAST ordering with forward pagination from a null cursor, there are no records after null, so it should return `{}` (empty filter). Currently it returns `NOT NULL`, which would incorrectly fetch records that appear before the null cursor.</comment>

<file context>
@@ -29,6 +31,96 @@ type BuildCursorCompositeFieldWhereConditionParams = {
+  computedOperator: string,
+  orderByDirection: OrderByDirection,
+): ObjectRecordFilter | Record&lt;string, never&gt; =&gt; {
+  if (computedOperator === &#39;gt&#39;) {
+    return {
+      [fieldKey]: {
</file context>
Suggested change
if (computedOperator === 'gt') {
return {
[fieldKey]: {
[firstProperty.name]: { is: 'NOT NULL' },
},
};
}
if (computedOperator === 'gt') {
const isNullsLast =
orderByDirection === OrderByDirection.AscNullsLast ||
orderByDirection === OrderByDirection.DescNullsLast;
if (isNullsLast) {
return {};
}
return {
[fieldKey]: {
[firstProperty.name]: { is: 'NOT NULL' },
},
};
}
Fix with Cubic


if (computedOperator === 'lt') {
return {
[fieldKey]: {
[firstProperty.name]: { is: 'NULL' },
},
};
}

return {};
};

const shouldIncludeNullsInComparison = (
computedOperator: string,
orderByDirection: OrderByDirection,
): boolean => {
const isNullsFirst =
orderByDirection === OrderByDirection.AscNullsFirst ||
orderByDirection === OrderByDirection.DescNullsFirst;

return (
(computedOperator === 'lt' && isNullsFirst) ||
(computedOperator === 'gt' && !isNullsFirst)
);
};

const buildComparisonFilterWithNulls = (
fieldKey: keyof ObjectRecord,
cursorKey: string,
computedOperator: string,
cursorValue: unknown,
): ObjectRecordFilter => {
return {
or: [
{
[fieldKey]: {
[cursorKey]: {
[computedOperator]: cursorValue,
},
},
},
{
[fieldKey]: {
[cursorKey]: {
is: 'NULL',
},
},
},
],
};
};

export const buildCursorCompositeFieldWhereCondition = ({
fieldType,
fieldKey,
Expand Down Expand Up @@ -64,36 +147,61 @@ export const buildCursorCompositeFieldWhereCondition = ({
return {};
}

const allSubFieldsAreNull = compositeFieldProperties.every((property) =>
isNull(cursorValue[property.name]),
);

if (allSubFieldsAreNull) {
if (isEqualityCondition) {
return buildNullCompositeFieldFilter(fieldKey, compositeFieldProperties);
}

const firstProperty = compositeFieldProperties[0];
const firstOrderByDirection = fieldOrderBy[fieldKey]?.[firstProperty.name];

if (!isDefined(firstOrderByDirection)) {
return {};
}

const isAscending = isAscendingOrder(firstOrderByDirection);
const computedOperator = computeOperator(isAscending, isForwardPagination);

return buildAllNullComparisonFilter(
fieldKey,
firstProperty,
computedOperator,
);
}

const cursorEntries = compositeFieldProperties
.map((property) => {
if (cursorValue[property.name] === undefined) {
return null;
}

if (isNull(cursorValue[property.name])) {
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Filtering null values from cursorEntries causes equality conditions to miss IS NULL checks for partially null composite fields. For a cursor like {firstName: 'John', lastName: null}, the equality filter won't include a check for lastName IS NULL, potentially matching incorrect records.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/twenty-server/src/engine/api/utils/build-cursor-composite-field-where-condition.utils.ts, line 192:

<comment>Filtering null values from `cursorEntries` causes equality conditions to miss `IS NULL` checks for partially null composite fields. For a cursor like `{firstName: &#39;John&#39;, lastName: null}`, the equality filter won&#39;t include a check for `lastName IS NULL`, potentially matching incorrect records.</comment>

<file context>
@@ -64,36 +156,62 @@ export const buildCursorCompositeFieldWhereCondition = ({
         return null;
       }
 
+      if (isNull(cursorValue[property.name])) {
+        return null;
+      }
</file context>
Fix with Cubic

return null;
}

return {
[property.name]: cursorValue[property.name],
};
})
.filter(isDefined);

if (isEqualityCondition) {
const result = cursorEntries.reduce<Record<string, ObjectRecordFilter>>(
(acc, cursorEntry) => {
const [cursorKey, cursorValue] = Object.entries(cursorEntry)[0];
const equalityFilters = cursorEntries.reduce<
Record<string, ObjectRecordFilter>
>((acc, cursorEntry) => {
const [cursorKey, cursorValue] = Object.entries(cursorEntry)[0];

return {
...acc,
[cursorKey]: {
eq: cursorValue,
},
};
},
{},
);
return {
...acc,
[cursorKey]: { eq: cursorValue },
};
}, {});

return {
[fieldKey]: result,
};
return { [fieldKey]: equalityFilters };
}

const orConditions = buildCursorCumulativeWhereCondition({
Expand Down Expand Up @@ -121,6 +229,15 @@ export const buildCursorCompositeFieldWhereCondition = ({
isForwardPagination,
);

if (shouldIncludeNullsInComparison(computedOperator, orderByDirection)) {
return buildComparisonFilterWithNulls(
fieldKey,
cursorKey,
computedOperator,
cursorValue,
);
}

return {
[fieldKey]: {
[cursorKey]: {
Expand Down
Loading