Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 018c13b6a7 fix: handle relation field join column names in cursor pagination
https://sonarly.com/issue/32426?type=bug

REST API cursor-based pagination fails with "Field metadata not found" when sorting by a relation field's join column name (e.g., `companyId`), because the cursor infrastructure only resolves field names, not join column names.

Fix: **What changed:** In `build-cursor-where-condition.utils.ts`, the field metadata lookup now falls back to `fieldIdByJoinColumnName` when `fieldIdByName` doesn't find a match for the cursor key. This mirrors the existing pattern in `GraphqlQueryOrderFieldParser.parse()` (line 72-74) which already resolves both field names (`company`) and join column names (`companyId`).

**Why:** When the REST API receives `order_by=companyId[AscNullsLast]`, the cursor is encoded with `companyId` as the key. On the next page request, `buildCursorWhereCondition` tried to find field metadata for `companyId` but only searched `fieldIdByName` (which maps `company` → field ID). The join column name `companyId` is stored in `fieldIdByJoinColumnName`, which was already returned by `buildFieldMapsFromFlatObjectMetadata` but never used in the cursor code.

**Test:** Added a test case in `compute-cursor-arg-filter.utils.spec.ts` that creates a RELATION field (`company` with `MANY_TO_ONE` relation type) and verifies that using the join column name (`companyId`) as a cursor key resolves correctly and produces the expected filter.
2026-04-29 11:45:12 +00:00
2 changed files with 51 additions and 7 deletions
@@ -1,4 +1,8 @@
import { FieldMetadataType, OrderByDirection } from 'twenty-shared/types';
import {
FieldMetadataType,
OrderByDirection,
RelationType,
} from 'twenty-shared/types';
import { GraphqlQueryRunnerException } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { computeCursorArgFilter } from 'src/engine/api/utils/compute-cursor-arg-filter.utils';
@@ -48,6 +52,18 @@ describe('computeCursorArgFilter', () => {
label: 'Age',
});
const companyField = createMockField({
id: 'company-id',
type: FieldMetadataType.RELATION,
name: 'company',
label: 'Company',
settings: { relationType: RelationType.MANY_TO_ONE },
} as Partial<FlatFieldMetadata> & {
id: string;
name: string;
type: FieldMetadataType;
});
const fullNameField = createMockField({
id: 'fullname-id',
type: FieldMetadataType.FULL_NAME,
@@ -80,6 +96,7 @@ describe('computeCursorArgFilter', () => {
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([
nameField,
ageField,
companyField,
fullNameField,
]);
@@ -101,7 +118,7 @@ describe('computeCursorArgFilter', () => {
createdAt: new Date(),
updatedAt: new Date(),
universalIdentifier: objectMetadataId,
fieldIds: ['name-id', 'age-id', 'fullname-id'],
fieldIds: ['name-id', 'age-id', 'company-id', 'fullname-id'],
indexMetadataIds: [],
viewIds: [],
applicationId: null,
@@ -135,6 +152,31 @@ describe('computeCursorArgFilter', () => {
expect(result).toEqual([{ name: { gt: 'John' } }]);
});
it('should resolve relation field join column name in cursor', () => {
const cursor = {
companyId: '6d093f0f-b5f5-42e2-936d-c3375c407cea',
};
const orderBy = [
{ companyId: OrderByDirection.AscNullsLast },
];
const result = computeCursorArgFilter(
cursor,
orderBy,
flatObjectMetadata,
flatFieldMetadataMaps,
true,
);
expect(result).toEqual([
{
companyId: {
gt: '6d093f0f-b5f5-42e2-936d-c3375c407cea',
},
},
]);
});
it('should compute backward pagination filter for single field', () => {
const cursor = { name: 'John' };
const orderBy = [{ name: OrderByDirection.AscNullsLast }];
@@ -44,12 +44,14 @@ export const buildCursorWhereCondition = ({
isForwardPagination,
isEqualityCondition = false,
}: BuildCursorWhereConditionParams): Record<string, unknown> => {
const { fieldIdByName } = buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
flatObjectMetadata,
);
const { fieldIdByName, fieldIdByJoinColumnName } =
buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
flatObjectMetadata,
);
const fieldMetadataId = fieldIdByName[cursorKey];
const fieldMetadataId =
fieldIdByName[cursorKey] ?? fieldIdByJoinColumnName[cursorKey];
const fieldMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityMaps: flatFieldMetadataMaps,