diff --git a/packages/twenty-docs/developers/extend/apps/data/objects.mdx b/packages/twenty-docs/developers/extend/apps/data/objects.mdx index 6f466443d6..4976cef49e 100644 --- a/packages/twenty-docs/developers/extend/apps/data/objects.mdx +++ b/packages/twenty-docs/developers/extend/apps/data/objects.mdx @@ -97,6 +97,27 @@ Unquoted strings are reserved for computed defaults, evaluated when a record is The same convention applies to string sub-fields of composite defaults (e.g. `{ source: "'MANUAL'" }` on an `ACTOR` field) and to `SELECT`/`MULTI_SELECT` values. A literal string default left unquoted raises a warning when your app is built. +## Nullability + +`isNullable` controls whether a field accepts `NULL`. It defaults to `true` — omit it for optional fields. Set `isNullable: false` to make a field required at the database level. + +Changes to `isNullable` are applied on every sync, including syncs that update an existing field — so you can flip a field's nullability by editing the manifest and re-syncing. + + +**Making an existing field non-nullable requires a default value.** When you change a field to `isNullable: false`, you must also provide a non-null `defaultValue`. The default backfills any existing `NULL` rows before the `NOT NULL` constraint is applied; without it the sync fails with `Default value cannot be null for non-nullable fields`. Relation fields and `TS_VECTOR` fields are always nullable, so `isNullable` has no effect on them. + + +```ts +{ + universalIdentifier: 'b1a7c0de-1234-4f00-9abc-000000000000', + name: 'reference', + type: FieldType.TEXT, + label: 'Reference', + isNullable: false, + defaultValue: "'N/A'", +} +``` + ## What's next - **Connect this object to others** — see [Relations](/developers/extend/apps/data/relations) for the bidirectional relation pattern. diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap index dd16e025d4..eb12f595f0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap @@ -75,6 +75,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma "standardOverrides", "universalSettings", "isUIEditable", + "isNullable", ], "propertiesToStringify": [ "defaultValue", diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts index b622cf67e9..08b2010f13 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts @@ -124,7 +124,7 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = { universalProperty: undefined, }, isNullable: { - toCompare: false, + toCompare: true, toStringify: false, universalProperty: undefined, }, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/__tests__/flat-entity-update.type-test.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/__tests__/flat-entity-update.type-test.ts index 63cdfa0a30..09ddf0f3f4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/__tests__/flat-entity-update.type-test.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/__tests__/flat-entity-update.type-test.ts @@ -23,6 +23,7 @@ type Assertions = [ | 'isUnique' | 'isLabelSyncedWithName' | 'isUIEditable' + | 'isNullable' | 'universalSettings' > >, diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-column-manager.service.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-column-manager.service.ts index 6022e611e5..943237274a 100644 --- a/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-column-manager.service.ts +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-schema-manager/services/workspace-schema-column-manager.service.ts @@ -104,4 +104,37 @@ export class WorkspaceSchemaColumnManagerService { await queryRunner.query(sql); } + + async alterColumnNullable({ + queryRunner, + schemaName, + tableName, + columnName, + isNullable, + backfillValue, + }: { + queryRunner: QueryRunner; + schemaName: string; + tableName: string; + columnName: string; + isNullable: boolean; + backfillValue?: string; + }): Promise { + const tableRef = `${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)}`; + const columnRef = escapeIdentifier(columnName); + + if ( + !isNullable && + backfillValue !== undefined && + backfillValue !== 'NULL' + ) { + await queryRunner.query( + `UPDATE ${tableRef} SET ${columnRef} = ${backfillValue} WHERE ${columnRef} IS NULL`, + ); + } + + await queryRunner.query( + `ALTER TABLE ${tableRef} ALTER COLUMN ${columnRef} ${isNullable ? 'DROP NOT NULL' : 'SET NOT NULL'}`, + ); + } } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/__tests__/universal-flat-entity-update.test-type.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/__tests__/universal-flat-entity-update.test-type.ts index 48f9ab013d..a4bc67a971 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/__tests__/universal-flat-entity-update.test-type.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/__tests__/universal-flat-entity-update.test-type.ts @@ -18,6 +18,7 @@ type Assertions = [ | 'isUnique' | 'isLabelSyncedWithName' | 'isUIEditable' + | 'isNullable' | 'universalSettings' > >, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/metadata-universal-flat-entity-properties-to-compare.type.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/metadata-universal-flat-entity-properties-to-compare.type.ts index 6df02f9173..db67349ea1 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/metadata-universal-flat-entity-properties-to-compare.type.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/metadata-universal-flat-entity-properties-to-compare.type.ts @@ -41,6 +41,7 @@ type Assertions = [ | 'isUnique' | 'isLabelSyncedWithName' | 'isUIEditable' + | 'isNullable' | 'universalSettings' > >, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field/services/update-field-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field/services/update-field-action-handler.service.ts index fb5b86cfde..48c42e4206 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field/services/update-field-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field/services/update-field-action-handler.service.ts @@ -76,6 +76,13 @@ type DefaultValueUpdateHandlerArgs< toDefaultValue: FlatFieldMetadata['defaultValue']; }; +type NullableUpdateHandlerArgs = Omit< + UpdateFieldPropertyHandlerArgs, + 'update' +> & { + toIsNullable: boolean; +}; + type OptionsUpdateHandlerArgs = UpdateFieldPropertyHandlerArgs & { toOptions: FlatFieldMetadata['options']; @@ -248,6 +255,19 @@ export class UpdateFieldActionHandlerService extends WorkspaceMigrationRunnerAct } } + if (update.isNullable !== undefined) { + const toIsNullable = update.isNullable ?? true; + + await this.handleFieldNullableUpdate({ + queryRunner, + schemaName, + tableName, + flatFieldMetadata: optimisticFlatFieldMetadata, + toIsNullable, + }); + optimisticFlatFieldMetadata.isNullable = toIsNullable; + } + if (isDefined(update.settings)) { // Handle onDelete change (for morph/relation fields) order matters if (isMorphOrRelationFlatFieldMetadata(optimisticFlatFieldMetadata)) { @@ -547,6 +567,86 @@ export class UpdateFieldActionHandlerService extends WorkspaceMigrationRunnerAct ); } + private async handleFieldNullableUpdate({ + flatFieldMetadata, + queryRunner, + schemaName, + tableName, + toIsNullable, + }: NullableUpdateHandlerArgs) { + if ( + isMorphOrRelationFlatFieldMetadata(flatFieldMetadata) || + isFlatFieldMetadataOfType(flatFieldMetadata, FieldMetadataType.TS_VECTOR) + ) { + return; + } + + if (isCompositeFlatFieldMetadata(flatFieldMetadata)) { + const compositeType = getCompositeTypeOrThrow(flatFieldMetadata.type); + + for (const property of compositeType.properties) { + if (isMorphOrRelationFieldMetadataType(property.type)) { + throw new WorkspaceMigrationActionExecutionException({ + message: + 'Relation field metadata in composite type is not supported yet', + code: WorkspaceMigrationActionExecutionExceptionCode.NOT_SUPPORTED, + }); + } + + const compositeColumnName = computeCompositeColumnName( + flatFieldMetadata.name, + property, + ); + const propertyIsNullable = toIsNullable || !property.isRequired; + const fieldDefaultValue = flatFieldMetadata.defaultValue; + // @ts-expect-error - composite default value is keyed by property name + const compositeDefaultValue = fieldDefaultValue?.[property.name]; + + await this.workspaceSchemaManagerService.columnManager.alterColumnNullable( + { + queryRunner, + schemaName, + tableName, + columnName: compositeColumnName, + isNullable: propertyIsNullable, + backfillValue: propertyIsNullable + ? undefined + : serializeDefaultValue({ + columnName: compositeColumnName, + schemaName, + tableName, + columnType: fieldMetadataTypeToColumnType( + property.type, + ) as ColumnType, + defaultValue: compositeDefaultValue, + }), + }, + ); + } + + return; + } + + await this.workspaceSchemaManagerService.columnManager.alterColumnNullable({ + queryRunner, + schemaName, + tableName, + columnName: flatFieldMetadata.name, + isNullable: toIsNullable, + backfillValue: toIsNullable + ? undefined + : serializeDefaultValue({ + columnName: flatFieldMetadata.name, + schemaName, + tableName, + columnType: fieldMetadataTypeToColumnType( + flatFieldMetadata.type, + ) as ColumnType, + defaultValue: flatFieldMetadata.defaultValue, + }), + }); + } + private async handleFieldOptionsUpdate({ flatFieldMetadata, queryRunner, diff --git a/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-field.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-field.integration-spec.ts index d1f8d97e03..3e47c2ebf9 100644 --- a/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-field.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-field.integration-spec.ts @@ -3,15 +3,21 @@ import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/app import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util'; import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util'; import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util'; +import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util'; +import { findOneOperationFactory } from 'test/integration/graphql/utils/find-one-operation-factory.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util'; import { findManyObjectMetadataWithIndexes } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata-with-indexes.util'; -import { type Manifest } from 'twenty-shared/application'; +import { type FieldManifest, type Manifest } from 'twenty-shared/application'; import { FieldMetadataType } from 'twenty-shared/types'; +import { capitalize, isDefined } from 'twenty-shared/utils'; import { v4 as uuidv4 } from 'uuid'; const TEST_APP_ID = uuidv4(); const TEST_ROLE_ID = uuidv4(); const TEST_FIELD_ID = uuidv4(); const TEST_SECOND_FIELD_ID = uuidv4(); +const TEST_NUMBER_FIELD_ID = uuidv4(); const TEST_OBJECT = buildDefaultObjectManifest({ nameSingular: 'ticket', @@ -41,6 +47,94 @@ const findObjectFields = async () => { return object?.fieldsList ?? []; }; +const findFieldWithNullable = async (fieldName: string) => { + const { objects } = await findManyObjectMetadata({ + expectToFail: false, + input: { + filter: {}, + paging: { first: 100 }, + }, + gqlFields: ` + id + universalIdentifier + fieldsList { + name + isNullable + } + `, + }); + + const object = objects.find( + (o) => o.universalIdentifier === TEST_OBJECT.universalIdentifier, + ); + + return object?.fieldsList?.find((field) => field.name === fieldName); +}; + +const buildReferenceFieldManifest = (isNullable: boolean): FieldManifest => ({ + universalIdentifier: TEST_FIELD_ID, + type: FieldMetadataType.TEXT, + name: 'reference', + label: 'Reference', + description: 'Ticket reference', + icon: 'IconFileDescription', + isNullable, + defaultValue: "'N/A'", + objectUniversalIdentifier: TEST_OBJECT.universalIdentifier, +}); + +// NUMBER is used here (rather than a TEXT field) because the data API +// coerces null/omitted TEXT values to '' via the null-equivalent processor, +// so a TEXT column can never actually hold NULL. NUMBER preserves NULL, which +// is what the nullable -> non-nullable backfill needs to act on. +const buildEstimateFieldManifest = ({ + isNullable, + defaultValue, +}: { + isNullable: boolean; + defaultValue?: number; +}): FieldManifest => ({ + universalIdentifier: TEST_NUMBER_FIELD_ID, + type: FieldMetadataType.NUMBER, + name: 'estimate', + label: 'Estimate', + description: 'Ticket estimate', + icon: 'IconNumber', + isNullable, + ...(isDefined(defaultValue) ? { defaultValue } : {}), + objectUniversalIdentifier: TEST_OBJECT.universalIdentifier, +}); + +const createTicketRecord = async (data: Record) => { + const response = await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: TEST_OBJECT.nameSingular, + gqlFields: ` + id + estimate + `, + data, + }), + ); + + return response.body.data?.[`create${capitalize(TEST_OBJECT.nameSingular)}`]; +}; + +const findTicketRecordById = async (recordId: string) => { + const response = await makeGraphqlAPIRequest( + findOneOperationFactory({ + objectMetadataSingularName: TEST_OBJECT.nameSingular, + gqlFields: ` + id + estimate + `, + filter: { id: { eq: recordId } }, + }), + ); + + return response.body.data?.[TEST_OBJECT.nameSingular]; +}; + describe('Manifest update - fields', () => { beforeEach(async () => { await setupApplicationForSync({ @@ -240,6 +334,75 @@ describe('Manifest update - fields', () => { ).toBeUndefined(); }, 60000); + it('should update isNullable when changed in manifest on second sync', async () => { + await syncApplication({ + manifest: buildManifest({ fields: [buildReferenceFieldManifest(true)] }), + expectToFail: false, + }); + + const fieldAfterFirstSync = await findFieldWithNullable('reference'); + + expect(fieldAfterFirstSync).toBeDefined(); + expect(fieldAfterFirstSync?.isNullable).toBe(true); + + await syncApplication({ + manifest: buildManifest({ fields: [buildReferenceFieldManifest(false)] }), + expectToFail: false, + }); + + const fieldAfterSecondSync = await findFieldWithNullable('reference'); + + expect(fieldAfterSecondSync?.isNullable).toBe(false); + + await syncApplication({ + manifest: buildManifest({ fields: [buildReferenceFieldManifest(true)] }), + expectToFail: false, + }); + + const fieldAfterThirdSync = await findFieldWithNullable('reference'); + + expect(fieldAfterThirdSync?.isNullable).toBe(true); + }, 60000); + + it('should backfill existing null rows when a field becomes non-nullable on second sync', async () => { + // First sync creates a nullable field with no default. + await syncApplication({ + manifest: buildManifest({ + fields: [buildEstimateFieldManifest({ isNullable: true })], + }), + expectToFail: false, + }); + + // Persist a record whose estimate is NULL on the underlying column. + const recordId = uuidv4(); + const createdRecord = await createTicketRecord({ + id: recordId, + estimate: null, + }); + + expect(createdRecord?.id).toBe(recordId); + expect(createdRecord?.estimate).toBeNull(); + + // Second sync makes the field non-nullable with a default value, which + // must backfill the existing NULL row before SET NOT NULL is enforced. + await syncApplication({ + manifest: buildManifest({ + fields: [ + buildEstimateFieldManifest({ isNullable: false, defaultValue: 42 }), + ], + }), + expectToFail: false, + }); + + const fieldAfterSecondSync = await findFieldWithNullable('estimate'); + + expect(fieldAfterSecondSync?.isNullable).toBe(false); + + const backfilledRecord = await findTicketRecordById(recordId); + + expect(backfilledRecord?.estimate).toBe(42); + }, 60000); + it('should create a unique index when field has isUnique set to true', async () => { await syncApplication({ manifest: buildManifest({