feat: add case-insensitive sorting for text fields (#17023)
## Context
Text fields were being sorted case-sensitively on the backend (e.g.,
'Apple', 'apple', 'Banana' would sort as 'Apple', 'Banana', 'apple').
This resulted in unexpected sorting behavior that differed between the
frontend Apollo cache sorting and backend database sorting.
## Changes
### Backend (packages/twenty-server)
- **`graphql-query-order.parser.ts`**:
- Added `shouldUseCaseInsensitiveOrder()` helper that returns `true` for
TEXT, SELECT, and MULTI_SELECT fields
- Added `buildOrderByColumnExpression()` method that wraps column
expressions with `LOWER()` for case-insensitive sorting
- Updated `parse()` and `parseObjectRecordOrderByForScalarField()` to
use the new helpers
- Updated `parseObjectRecordOrderByForRelationField()` to apply LOWER()
to nested text fields
- **`parse-composite-field-for-order.util.ts`**:
- Added `shouldUseCaseInsensitiveOrder()` helper for composite subfields
- Updated composite field parsing to apply `LOWER()` to subfields of
type TEXT (e.g., `name.firstName`, `name.lastName`)
### Frontend (packages/twenty-front)
- **`sort.ts`**:
- Updated `sortAsc()` to use case-insensitive comparison
(`toLowerCase()`) for string values
- `sortDesc()` automatically benefits from this since it delegates to
`sortAsc()`
- Ensures Apollo cache sorting matches backend behavior
## Example
Before:
```sql
ORDER BY "person"."name" ASC
```
Result: ['Apple', 'Banana', 'apple', 'cherry']
After:
```sql
ORDER BY LOWER("person"."name") ASC
```
Result: ['apple', 'Apple', 'Banana', 'cherry']
## Testing
- Manual testing of sorting on People and Companies views
- Verified frontend cache sorting matches backend results
This commit is contained in:
+87
@@ -0,0 +1,87 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import {
|
||||
buildOrderByColumnExpression,
|
||||
shouldCastToText,
|
||||
shouldUseCaseInsensitiveOrder,
|
||||
} from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/build-order-by-column-expression.util';
|
||||
|
||||
describe('buildOrderByColumnExpression', () => {
|
||||
describe('returns unquoted column expressions for TypeORM', () => {
|
||||
it('should return unquoted alias.column format', () => {
|
||||
const result = buildOrderByColumnExpression('company', 'name');
|
||||
|
||||
expect(result).toBe('company.name');
|
||||
});
|
||||
|
||||
it('should work with different prefixes', () => {
|
||||
const result = buildOrderByColumnExpression('assignee', 'email');
|
||||
|
||||
expect(result).toBe('assignee.email');
|
||||
});
|
||||
|
||||
it('should handle composite column names (e.g., nameFirstName)', () => {
|
||||
const result = buildOrderByColumnExpression('person', 'nameFirstName');
|
||||
|
||||
expect(result).toBe('person.nameFirstName');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldUseCaseInsensitiveOrder', () => {
|
||||
it('should return true for TEXT fields', () => {
|
||||
expect(shouldUseCaseInsensitiveOrder(FieldMetadataType.TEXT)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for SELECT fields', () => {
|
||||
expect(shouldUseCaseInsensitiveOrder(FieldMetadataType.SELECT)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for MULTI_SELECT fields', () => {
|
||||
expect(shouldUseCaseInsensitiveOrder(FieldMetadataType.MULTI_SELECT)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false for NUMBER fields', () => {
|
||||
expect(shouldUseCaseInsensitiveOrder(FieldMetadataType.NUMBER)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for DATE_TIME fields', () => {
|
||||
expect(shouldUseCaseInsensitiveOrder(FieldMetadataType.DATE_TIME)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false for UUID fields', () => {
|
||||
expect(shouldUseCaseInsensitiveOrder(FieldMetadataType.UUID)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for BOOLEAN fields', () => {
|
||||
expect(shouldUseCaseInsensitiveOrder(FieldMetadataType.BOOLEAN)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldCastToText', () => {
|
||||
it('should return true for SELECT fields', () => {
|
||||
expect(shouldCastToText(FieldMetadataType.SELECT)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for MULTI_SELECT fields', () => {
|
||||
expect(shouldCastToText(FieldMetadataType.MULTI_SELECT)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for TEXT fields', () => {
|
||||
expect(shouldCastToText(FieldMetadataType.TEXT)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for NUMBER fields', () => {
|
||||
expect(shouldCastToText(FieldMetadataType.NUMBER)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for DATE_TIME fields', () => {
|
||||
expect(shouldCastToText(FieldMetadataType.DATE_TIME)).toBe(false);
|
||||
});
|
||||
});
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { parseCompositeFieldForOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
|
||||
describe('parseCompositeFieldForOrder', () => {
|
||||
describe('case-insensitive sorting for composite subfields', () => {
|
||||
it('should set useLower: true for TEXT subfields in FULL_NAME composite', () => {
|
||||
const fieldMetadata = {
|
||||
type: FieldMetadataType.FULL_NAME,
|
||||
name: 'name',
|
||||
} as FlatFieldMetadata;
|
||||
|
||||
const result = parseCompositeFieldForOrder(
|
||||
fieldMetadata,
|
||||
{ firstName: 'AscNullsFirst' },
|
||||
'person',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
'person.nameFirstName': {
|
||||
order: 'ASC',
|
||||
nulls: 'NULLS FIRST',
|
||||
useLower: true,
|
||||
castToText: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should set useLower: true for TEXT subfields in LINKS composite', () => {
|
||||
const fieldMetadata = {
|
||||
type: FieldMetadataType.LINKS,
|
||||
name: 'linkedinLink',
|
||||
} as FlatFieldMetadata;
|
||||
|
||||
const result = parseCompositeFieldForOrder(
|
||||
fieldMetadata,
|
||||
{ primaryLinkLabel: 'DescNullsLast' },
|
||||
'company',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
'company.linkedinLinkPrimaryLinkLabel': {
|
||||
order: 'DESC',
|
||||
nulls: 'NULLS LAST',
|
||||
useLower: true,
|
||||
castToText: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should set useLower: true for TEXT subfields in ADDRESS composite', () => {
|
||||
const fieldMetadata = {
|
||||
type: FieldMetadataType.ADDRESS,
|
||||
name: 'address',
|
||||
} as FlatFieldMetadata;
|
||||
|
||||
const result = parseCompositeFieldForOrder(
|
||||
fieldMetadata,
|
||||
{ addressCity: 'AscNullsLast' },
|
||||
'company',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
'company.addressAddressCity': {
|
||||
order: 'ASC',
|
||||
nulls: 'NULLS LAST',
|
||||
useLower: true,
|
||||
castToText: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('case-sensitive sorting for non-text composite subfields', () => {
|
||||
it('should set useLower: false for non-TEXT subfields in CURRENCY composite', () => {
|
||||
const fieldMetadata = {
|
||||
type: FieldMetadataType.CURRENCY,
|
||||
name: 'annualRevenue',
|
||||
} as FlatFieldMetadata;
|
||||
|
||||
const result = parseCompositeFieldForOrder(
|
||||
fieldMetadata,
|
||||
{ amountMicros: 'DescNullsFirst' },
|
||||
'company',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
'company.annualRevenueAmountMicros': {
|
||||
order: 'DESC',
|
||||
nulls: 'NULLS FIRST',
|
||||
useLower: false,
|
||||
castToText: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should set useLower: false for non-TEXT subfields in ADDRESS lat/lng', () => {
|
||||
const fieldMetadata = {
|
||||
type: FieldMetadataType.ADDRESS,
|
||||
name: 'address',
|
||||
} as FlatFieldMetadata;
|
||||
|
||||
const result = parseCompositeFieldForOrder(
|
||||
fieldMetadata,
|
||||
{ addressLat: 'AscNullsFirst' },
|
||||
'company',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
'company.addressAddressLat': {
|
||||
order: 'ASC',
|
||||
nulls: 'NULLS FIRST',
|
||||
useLower: false,
|
||||
castToText: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination direction handling', () => {
|
||||
it('should reverse order direction for backward pagination', () => {
|
||||
const fieldMetadata = {
|
||||
type: FieldMetadataType.FULL_NAME,
|
||||
name: 'name',
|
||||
} as FlatFieldMetadata;
|
||||
|
||||
const result = parseCompositeFieldForOrder(
|
||||
fieldMetadata,
|
||||
{ firstName: 'AscNullsFirst' },
|
||||
'person',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
'person.nameFirstName': {
|
||||
order: 'DESC',
|
||||
nulls: 'NULLS FIRST',
|
||||
useLower: true,
|
||||
castToText: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw error for invalid subfield name', () => {
|
||||
const fieldMetadata = {
|
||||
type: FieldMetadataType.FULL_NAME,
|
||||
name: 'name',
|
||||
} as FlatFieldMetadata;
|
||||
|
||||
expect(() =>
|
||||
parseCompositeFieldForOrder(
|
||||
fieldMetadata,
|
||||
{ invalidSubField: 'AscNullsFirst' },
|
||||
'person',
|
||||
true,
|
||||
),
|
||||
).toThrow('Sub field metadata not found');
|
||||
});
|
||||
|
||||
it('should throw error for invalid order direction', () => {
|
||||
const fieldMetadata = {
|
||||
type: FieldMetadataType.FULL_NAME,
|
||||
name: 'name',
|
||||
} as FlatFieldMetadata;
|
||||
|
||||
expect(() =>
|
||||
parseCompositeFieldForOrder(
|
||||
fieldMetadata,
|
||||
{ firstName: 'InvalidDirection' },
|
||||
'person',
|
||||
true,
|
||||
),
|
||||
).toThrow('Sub field order by value must be of type OrderByDirection');
|
||||
});
|
||||
});
|
||||
});
|
||||
+26
-12
@@ -8,8 +8,12 @@ import {
|
||||
GraphqlQueryRunnerException,
|
||||
GraphqlQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
|
||||
import {
|
||||
buildOrderByColumnExpression,
|
||||
shouldCastToText,
|
||||
shouldUseCaseInsensitiveOrder,
|
||||
} from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/build-order-by-column-expression.util';
|
||||
import { convertOrderByToFindOptionsOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order';
|
||||
import { getOptionalOrderByCasting } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/get-optional-order-by-casting.util';
|
||||
import { isOrderByDirection } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/is-order-by-direction.util';
|
||||
import { parseCompositeFieldForOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
@@ -130,14 +134,19 @@ export class GraphqlQueryOrderFieldParser {
|
||||
);
|
||||
}
|
||||
|
||||
const orderByCasting = getOptionalOrderByCasting(fieldMetadata);
|
||||
|
||||
orderByConditions[
|
||||
`${objectNameSingular}.${fieldName}${orderByCasting}`
|
||||
] = convertOrderByToFindOptionsOrder(
|
||||
orderByDirection,
|
||||
isForwardPagination,
|
||||
const columnExpression = buildOrderByColumnExpression(
|
||||
objectNameSingular,
|
||||
fieldName,
|
||||
);
|
||||
|
||||
orderByConditions[columnExpression] = {
|
||||
...convertOrderByToFindOptionsOrder(
|
||||
orderByDirection,
|
||||
isForwardPagination,
|
||||
),
|
||||
useLower: shouldUseCaseInsensitiveOrder(fieldMetadata.type),
|
||||
castToText: shouldCastToText(fieldMetadata.type),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,16 +245,21 @@ export class GraphqlQueryOrderFieldParser {
|
||||
}
|
||||
|
||||
if (isOrderByDirection(nestedFieldOrderByValue)) {
|
||||
const nestedColumnName = nestedFieldMetadata.name;
|
||||
const orderByCasting = getOptionalOrderByCasting(nestedFieldMetadata);
|
||||
const columnExpression = buildOrderByColumnExpression(
|
||||
joinAlias,
|
||||
nestedFieldMetadata.name,
|
||||
);
|
||||
|
||||
return {
|
||||
orderBy: {
|
||||
[`${joinAlias}.${nestedColumnName}${orderByCasting}`]:
|
||||
convertOrderByToFindOptionsOrder(
|
||||
[columnExpression]: {
|
||||
...convertOrderByToFindOptionsOrder(
|
||||
nestedFieldOrderByValue,
|
||||
isForwardPagination,
|
||||
),
|
||||
useLower: shouldUseCaseInsensitiveOrder(nestedFieldMetadata.type),
|
||||
castToText: shouldCastToText(nestedFieldMetadata.type),
|
||||
},
|
||||
},
|
||||
joinInfo,
|
||||
};
|
||||
|
||||
+2
@@ -1,4 +1,6 @@
|
||||
export type OrderByClause = {
|
||||
order: 'ASC' | 'DESC';
|
||||
nulls?: 'NULLS FIRST' | 'NULLS LAST';
|
||||
useLower?: boolean;
|
||||
castToText?: boolean; // For SELECT/MULTI_SELECT fields that need ::text before LOWER
|
||||
};
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
export const shouldUseCaseInsensitiveOrder = (
|
||||
fieldType: FieldMetadataType,
|
||||
): boolean => {
|
||||
return (
|
||||
fieldType === FieldMetadataType.TEXT ||
|
||||
fieldType === FieldMetadataType.SELECT ||
|
||||
fieldType === FieldMetadataType.MULTI_SELECT
|
||||
);
|
||||
};
|
||||
|
||||
export const shouldCastToText = (fieldType: FieldMetadataType): boolean => {
|
||||
return (
|
||||
fieldType === FieldMetadataType.SELECT ||
|
||||
fieldType === FieldMetadataType.MULTI_SELECT
|
||||
);
|
||||
};
|
||||
|
||||
// Returns unquoted column expression for TypeORM's orderBy (e.g., "company.name")
|
||||
// Quoting and LOWER() wrapping is handled in getOrderByRawSQL
|
||||
export const buildOrderByColumnExpression = (
|
||||
prefix: string,
|
||||
columnName: string,
|
||||
): string => {
|
||||
return `${prefix}.${columnName}`;
|
||||
};
|
||||
+15
-5
@@ -2,6 +2,11 @@ import { compositeTypeDefinitions } from 'twenty-shared/types';
|
||||
import { capitalize } from 'twenty-shared/utils';
|
||||
|
||||
import { type OrderByClause } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser';
|
||||
import {
|
||||
buildOrderByColumnExpression,
|
||||
shouldCastToText,
|
||||
shouldUseCaseInsensitiveOrder,
|
||||
} from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/build-order-by-column-expression.util';
|
||||
import { convertOrderByToFindOptionsOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order';
|
||||
import { isOrderByDirection } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/is-order-by-direction.util';
|
||||
import { type CompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/composite-field-metadata-type.type';
|
||||
@@ -35,17 +40,22 @@ export const parseCompositeFieldForOrder = (
|
||||
);
|
||||
}
|
||||
|
||||
const fullFieldName = `${prefix}.${fieldMetadata.name}${capitalize(subFieldKey)}`;
|
||||
const orderByKey = buildOrderByColumnExpression(
|
||||
prefix,
|
||||
`${fieldMetadata.name}${capitalize(subFieldKey)}`,
|
||||
);
|
||||
|
||||
if (!isOrderByDirection(subFieldValue)) {
|
||||
throw new Error(
|
||||
`Sub field order by value must be of type OrderByDirection, but got: ${subFieldValue}`,
|
||||
);
|
||||
}
|
||||
acc[fullFieldName] = convertOrderByToFindOptionsOrder(
|
||||
subFieldValue,
|
||||
isForwardPagination,
|
||||
);
|
||||
|
||||
acc[orderByKey] = {
|
||||
...convertOrderByToFindOptionsOrder(subFieldValue, isForwardPagination),
|
||||
useLower: shouldUseCaseInsensitiveOrder(subFieldMetadata.type),
|
||||
castToText: shouldCastToText(subFieldMetadata.type),
|
||||
};
|
||||
|
||||
return acc;
|
||||
},
|
||||
|
||||
+18
-1
@@ -179,7 +179,24 @@ export class GraphqlQueryParser {
|
||||
? ` ${orderByCondition.nulls}`
|
||||
: '';
|
||||
|
||||
return `${orderByField} ${orderByCondition.order}${nullsCondition}`;
|
||||
// Convert "alias.column" to quoted SQL identifier "alias"."column"
|
||||
const parts = orderByField.split('.');
|
||||
const quotedColumn =
|
||||
parts.length === 2
|
||||
? `"${parts[0]}"."${parts[1]}"`
|
||||
: `"${orderByField}"`;
|
||||
|
||||
// Build column expression with optional ::text cast and LOWER()
|
||||
let columnExpr = quotedColumn;
|
||||
|
||||
if (orderByCondition.castToText) {
|
||||
columnExpr = `${columnExpr}::text`;
|
||||
}
|
||||
if (orderByCondition.useLower) {
|
||||
columnExpr = `LOWER(${columnExpr})`;
|
||||
}
|
||||
|
||||
return `${columnExpr} ${orderByCondition.order}${nullsCondition}`;
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user