fix(server): skip defaultValue null check for relation/morph fields on update (#21875)
## Description
Updating any metadata property (e.g. `description`, `label`) of an
existing **non-nullable RELATION** field fails with:
```
INVALID_FIELD_INPUT: Default value cannot be null for non-nullable fields
```
A relation field has no literal `defaultValue` (it's always `null`), so
the update-path validator rejects every required relation. **Creating**
the same field is fine — only **updates** fail.
This also blocks any incremental app re-sync (`yarn twenty dev --once`)
whose diff touches a required relation field.
## Fix
Added a guard in
`FlatFieldMetadataValidatorService.validateFlatFieldMetadataUpdate()`
using the already-imported `isMorphOrRelationUniversalFlatFieldMetadata`
utility to skip the `defaultValue === null` check for relation/morph
field types:
```diff
if (
+ !isMorphOrRelationUniversalFlatFieldMetadata(
+ flatFieldMetadataToValidate,
+ ) &&
flatFieldMetadataToValidate.isNullable === false &&
flatFieldMetadataToValidate.defaultValue === null
) {
```
### Why this works:
- Relation fields represent foreign key relationships, not columns with
literal defaults
- The same guard is already used at line 144 in the same method for
relation-specific validation
- The create path (`validateFlatFieldMetadataCreation`) never had this
check, which is why creation always worked
- No new imports needed — `isMorphOrRelationUniversalFlatFieldMetadata`
is already imported on line 14
## Verification
- `npx nx build twenty-server` ✅ compiles successfully
Fixes #21751
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21875?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
---------
Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: prastoin <paul@twenty.com>
This commit is contained in:
+2
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
|
||||
import { isFieldMetadataTypeWithDefaultValue } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
|
||||
@@ -191,6 +192,7 @@ export class FlatFieldMetadataValidatorService {
|
||||
}
|
||||
|
||||
if (
|
||||
isFieldMetadataTypeWithDefaultValue(flatFieldMetadataToValidate.type) &&
|
||||
flatFieldMetadataToValidate.isNullable === false &&
|
||||
flatFieldMetadataToValidate.defaultValue === null
|
||||
) {
|
||||
|
||||
+50
@@ -142,6 +142,56 @@ describe('Field metadata relation update should succeed', () => {
|
||||
expect(data).toBeDefined();
|
||||
expect(data.updateOneField.name).toBe('leadEmployer');
|
||||
});
|
||||
|
||||
// Regression test for https://github.com/twentyhq/twenty/issues/21751: a non-nullable relation has no literal defaultValue and must still be updatable.
|
||||
it('should successfully update a non-nullable relation field', async () => {
|
||||
const {
|
||||
data: {
|
||||
createOneField: { id: nonNullableRelationFieldId, isNullable },
|
||||
},
|
||||
} = await createOneFieldMetadata({
|
||||
input: {
|
||||
objectMetadataId: globalTestContext.employeeObjectId,
|
||||
name: 'mandatoryEmployer',
|
||||
label: 'Mandatory employer',
|
||||
isLabelSyncedWithName: false,
|
||||
isNullable: false,
|
||||
type: FieldMetadataType.RELATION,
|
||||
relationCreationPayload: {
|
||||
targetFieldLabel: 'mandatoryEmployees',
|
||||
type: RelationType.MANY_TO_ONE,
|
||||
targetObjectMetadataId: globalTestContext.enterpriseObjectId,
|
||||
targetFieldIcon: 'IconBuildingSkyscraper',
|
||||
},
|
||||
},
|
||||
gqlFields: `
|
||||
id
|
||||
isNullable
|
||||
`,
|
||||
});
|
||||
|
||||
expect(isNullable).toBe(false);
|
||||
|
||||
const { data, errors } = await updateOneFieldMetadata({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
idToUpdate: nonNullableRelationFieldId,
|
||||
updatePayload: {
|
||||
description: 'Updated description for a required relation',
|
||||
},
|
||||
},
|
||||
gqlFields: `
|
||||
id
|
||||
description
|
||||
`,
|
||||
});
|
||||
|
||||
expect(errors).toBeUndefined();
|
||||
expect(data).toBeDefined();
|
||||
expect(data.updateOneField.description).toBe(
|
||||
'Updated description for a required relation',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Field metadata self-relation update should succeed', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type LinkMetadata } from '@/types/composite-types/links.composite-type';
|
||||
import { type FieldMetadataType } from '@/types/FieldMetadataType';
|
||||
import { FieldMetadataType } from '@/types/FieldMetadataType';
|
||||
import { type IsExactly } from '@/types/IsExactly';
|
||||
|
||||
export const fieldMetadataDefaultValueFunctionName = {
|
||||
@@ -111,3 +111,17 @@ export type FieldMetadataDefaultValue<
|
||||
: T extends keyof FieldMetadataDefaultValueMapping
|
||||
? FieldMetadataDefaultValueMapping[T]
|
||||
: never | null;
|
||||
|
||||
export const FIELD_METADATA_TYPES_WITHOUT_DEFAULT_VALUE = [
|
||||
FieldMetadataType.RELATION,
|
||||
FieldMetadataType.MORPH_RELATION,
|
||||
FieldMetadataType.FILES,
|
||||
FieldMetadataType.TS_VECTOR,
|
||||
] as const satisfies FieldMetadataType[];
|
||||
|
||||
export const isFieldMetadataTypeWithDefaultValue = (
|
||||
type: FieldMetadataType,
|
||||
): boolean =>
|
||||
!(
|
||||
FIELD_METADATA_TYPES_WITHOUT_DEFAULT_VALUE as readonly FieldMetadataType[]
|
||||
).includes(type);
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { type Equal, type Expect } from '@/testing';
|
||||
import {
|
||||
type FieldMetadataDefaultValueMapping,
|
||||
type FIELD_METADATA_TYPES_WITHOUT_DEFAULT_VALUE,
|
||||
} from '@/types/FieldMetadataDefaultValue';
|
||||
import { type FieldMetadataType } from '@/types/FieldMetadataType';
|
||||
|
||||
type ClassifiedFieldMetadataType =
|
||||
| keyof FieldMetadataDefaultValueMapping
|
||||
| (typeof FIELD_METADATA_TYPES_WITHOUT_DEFAULT_VALUE)[number];
|
||||
|
||||
type Unclassified = Exclude<FieldMetadataType, ClassifiedFieldMetadataType>;
|
||||
type ExtraClassified = Exclude<ClassifiedFieldMetadataType, FieldMetadataType>;
|
||||
|
||||
// oxlint-disable-next-line unused-imports/no-unused-vars
|
||||
type Assertions = [
|
||||
Expect<Equal<Unclassified, never>>,
|
||||
Expect<Equal<ExtraClassified, never>>,
|
||||
];
|
||||
@@ -92,7 +92,11 @@ export type {
|
||||
FieldMetadataDefaultValueForAnyType,
|
||||
FieldMetadataDefaultValue,
|
||||
} from './FieldMetadataDefaultValue';
|
||||
export { fieldMetadataDefaultValueFunctionName } from './FieldMetadataDefaultValue';
|
||||
export {
|
||||
fieldMetadataDefaultValueFunctionName,
|
||||
FIELD_METADATA_TYPES_WITHOUT_DEFAULT_VALUE,
|
||||
isFieldMetadataTypeWithDefaultValue,
|
||||
} from './FieldMetadataDefaultValue';
|
||||
export type { FieldMetadataMultiItemSettings } from './FieldMetadataMultiItemSettings';
|
||||
export { FieldMetadataSettingsOnClickAction } from './FieldMetadataMultiItemSettings';
|
||||
export type {
|
||||
|
||||
Reference in New Issue
Block a user