[TYPES] UniversalEntity JsonbProperty and SerializedRelation (#17396)

# Introduction

In this PR we're introducing mainly two branded type signatures for both
`JsonbProperty` entities properties and `SerializedRelation` (jsonb
serialized property storing another entity id).

Allowing to dynamically map over them later in order to build universal
`jsonb` `serialized` relations.

## `JsonbProperty`

A branded wrapper type that marks entity properties stored as PostgreSQL
JSONB columns. It adds a phantom brand `__JsonbPropertyBrand__` to
object types while leaving primitives unchanged. The branded key is
optional and typed as never, also omitted when transpiled to
`UniversalFlat`

**Should be used at entities lvl only:**
```typescript
@Column({ type: 'jsonb', nullable: false })
gridPosition: JsonbProperty<GridPosition>;

@Column({ nullable: false, type: 'jsonb', default: [] })
publishedVersions: JsonbProperty<string[]>;
```

## `SerializedRelation`

A branded string type that marks foreign key IDs stored inside JSONB
objects. These are entity references serialized within a JSONB column
rather than being a regular database foreign key.

**Usage in jsonb property generic***
```ts
type FieldMetadataRelationSettings = {
  relationType: RelationType;
  onDelete?: RelationOnDeleteAction;
  joinColumnName?: string | null;
  junctionTargetFieldId?: SerializedRelation;
};
```

## `FormatJsonbSerializedRelation<T>`

A transformation type that processes JSONB properties for universal
entity mapping. It:
1. Detects properties with the `JsonbProperty` brand
2. Finds `SerializedRelation` properties
3. Renames them from `*Id` to `*UniversalIdentifier`
4. Removes the brand from the output type ( optional though )

```typescript
// Input: JsonbProperty<{ targetFieldMetadataId: SerializedRelation }>
// Output: { targetFieldMetadataUniversalIdentifier: SerializedRelation }
```

## Result
An example of the dynamic type mapping, through a type-test example
```ts
type SettingsTestCase = UniversalFlatFieldMetadata<
    | FieldMetadataType.RELATION
    | FieldMetadataType.NUMBER
    | FieldMetadataType.TEXT
  >['settings']

type SettingsExpectedResult =
  | {
      relationType: RelationType;
      onDelete?: RelationOnDeleteAction | undefined;
      joinColumnName?: string | null | undefined;
      junctionTargetFieldUniversalIdentifier?: SerializedRelation | undefined;
    }
  | {
      dataType?: NumberDataType | undefined;
      decimals?: number | undefined;
      type?: FieldNumberVariant | undefined;
    }
  | {
      displayedMaxRows?: number | undefined;
    }
  | null;

type Assertions = [
  Expect<Equal<SettingsTestCase, SettingsExpectedResult>>,
]
```

## Remarks

- Removed duplicated twenty-server and twenty-shared typed
- Removed class validator instances for default value that were not used
at runtime, we will refactor that to add validation across all entities
following a same pattern
This commit is contained in:
Paul Rastoin 2026-01-26 15:25:43 +01:00 committed by GitHub
parent d0bc9a94c0
commit 44202668fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 1082 additions and 1070 deletions

View file

@ -27,6 +27,7 @@ Examples of existing syncable entities: `skill`, `agent`, `view`, `viewField`, `
3. [Step-by-Step Implementation](#step-by-step-implementation)
- [Step 1: Add Metadata Name Constant](#step-1-add-metadata-name-constant-twenty-shared)
- [Step 2: Create TypeORM Entity](#step-2-create-typeorm-entity)
- [Step 2b: Using JsonbProperty and SerializedRelation Types](#step-2b-using-jsonbproperty-and-serializedrelation-types)
- [Step 3: Define Flat Entity Type](#step-3-define-flat-entity-type)
- [Step 4: Define Editable Properties](#step-4-define-editable-properties)
- [Step 5: Register in Central Constants](#step-5-register-in-central-constants)
@ -237,6 +238,160 @@ export abstract class WorkspaceRelatedEntity {
---
### Step 2b: Using JsonbProperty and SerializedRelation Types
When your entity has JSONB columns or stores foreign key references inside JSONB structures, you must use the branded type wrappers to enable automatic universal identifier mapping.
#### JsonbProperty Wrapper
Wrap all JSONB column types with `JsonbProperty<T>` to mark them for the universal entity transformation system:
```typescript
import { JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
@Entity('myEntity')
export class MyEntityEntity extends SyncableEntity {
// Simple JSONB column - wrap the type
@Column({ type: 'jsonb', nullable: true })
settings: JsonbProperty<MyEntitySettings> | null;
// JSONB column with complex type
@Column({ type: 'jsonb', nullable: false })
configuration: JsonbProperty<MyEntityConfiguration>;
// Array stored as JSONB
@Column({ type: 'jsonb', nullable: true })
tags: JsonbProperty<string[]> | null;
}
```
**When to use `JsonbProperty<T>`:**
- Any column with `type: 'jsonb'` that stores an object or array
- Configuration objects, settings, metadata blobs
- Any structured data stored as JSON in the database
**What it enables:**
- The type system can identify which properties are JSONB columns
- Automatic transformation of serialized relations within JSONB structures
- Type-safe universal entity mapping
#### SerializedRelation Type
Use `SerializedRelation` for properties **inside JSONB structures** that store foreign key references (entity IDs):
```typescript
import { SerializedRelation } from 'twenty-shared/types';
import { JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
// Define the JSONB structure type
type MyEntityConfiguration = {
name: string;
// This stores a reference to another field's ID - use SerializedRelation
targetFieldMetadataId: SerializedRelation;
// This stores a reference to an object's ID
sourceObjectMetadataId: SerializedRelation;
// Regular string - NOT a foreign key reference
displayFormat: string;
};
@Entity('myEntity')
export class MyEntityEntity extends SyncableEntity {
@Column({ type: 'jsonb', nullable: false })
configuration: JsonbProperty<MyEntityConfiguration>;
}
```
**When to use `SerializedRelation`:**
- Properties inside JSONB that store UUIDs referencing other entities
- Foreign key relationships that can't use TypeORM relations (because they're in JSONB)
- Any `*Id` property inside a JSONB structure that references another metadata entity
**What it enables:**
- Automatic renaming from `*Id` to `*UniversalIdentifier` in universal entities
- Type-safe extraction of serialized relation properties
- Proper handling during workspace sync/migration
#### Complete Example
```typescript
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { SerializedRelation } from 'twenty-shared/types';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
import { JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
// JSONB structure with serialized relations
type WidgetConfiguration = {
title: string;
// Foreign keys stored in JSONB - use SerializedRelation
fieldMetadataId: SerializedRelation;
objectMetadataId: SerializedRelation;
// Optional foreign key
viewId?: SerializedRelation;
// Regular properties (not foreign keys)
displayMode: 'compact' | 'expanded';
maxItems: number;
};
type GridPosition = {
row: number;
column: number;
width: number;
height: number;
};
@Entity('widget')
export class WidgetEntity extends SyncableEntity implements Required<WidgetEntity> {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: true, type: 'uuid' })
standardId: string | null;
@Column({ nullable: false })
name: string;
// JSONB column with serialized relations - wrap with JsonbProperty
@Column({ type: 'jsonb', nullable: false })
configuration: JsonbProperty<WidgetConfiguration>;
// JSONB column without serialized relations - still wrap with JsonbProperty
@Column({ type: 'jsonb', nullable: false })
gridPosition: JsonbProperty<GridPosition>;
@Column({ default: false })
isCustom: boolean;
// ... other columns
}
```
**Result in Universal Entity:**
When transformed to a universal entity, the `configuration` property will have its `SerializedRelation` fields automatically renamed:
```typescript
// Original (in database/flat entity)
{
fieldMetadataId: "abc-123",
objectMetadataId: "def-456",
viewId: "ghi-789",
displayMode: "compact",
maxItems: 10,
}
// Transformed (in universal entity)
{
fieldMetadataUniversalIdentifier: "abc-123",
objectMetadataUniversalIdentifier: "def-456",
viewUniversalIdentifier: "ghi-789",
displayMode: "compact",
maxItems: 10,
}
```
---
### Step 3: Define Flat Entity Type
**File:** `src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type.ts`
@ -1143,6 +1298,11 @@ Before considering your syncable entity complete, verify:
- [ ] Entity has `isCustom` boolean column
- [ ] Entity-to-flat transform sets `universalIdentifier` correctly (`standardId || id`)
### JSONB Properties and Serialized Relations
- [ ] All JSONB columns are wrapped with `JsonbProperty<T>`
- [ ] Foreign key references inside JSONB structures use `SerializedRelation` type
- [ ] JSONB structure types are properly defined with `SerializedRelation` for `*Id` properties
### Registration (twenty-shared)
- [ ] Metadata name added to `ALL_METADATA_NAME`

View file

@ -4,7 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { STANDARD_OBJECT_IDS } from 'twenty-shared/metadata';
import {
FieldMetadataRelationSettings,
FieldMetadataSettingsMapping,
FieldMetadataType,
RelationOnDeleteAction,
} from 'twenty-shared/types';
@ -106,7 +106,7 @@ export class UpdateTaskOnDeleteActionCommand extends ActiveOrSuspendedWorkspaces
}
const taskFieldSettings =
taskField.settings as FieldMetadataRelationSettings;
taskField.settings as FieldMetadataSettingsMapping['RELATION'];
if (taskFieldSettings?.onDelete === RelationOnDeleteAction.CASCADE) {
this.logger.log(
@ -121,7 +121,7 @@ export class UpdateTaskOnDeleteActionCommand extends ActiveOrSuspendedWorkspaces
);
if (!isDryRun) {
const updatedSettings: FieldMetadataRelationSettings = {
const updatedSettings: FieldMetadataSettingsMapping['RELATION'] = {
...taskFieldSettings,
onDelete: RelationOnDeleteAction.CASCADE,
};

View file

@ -3,8 +3,7 @@ import { Injectable } from '@nestjs/common';
import { msg } from '@lingui/core/macro';
import { isNull, isUndefined } from '@sniptt/guards';
import {
FieldMetadataFilesSettings,
FieldMetadataRelationSettings,
FieldMetadataSettingsMapping,
FieldMetadataType,
ObjectRecord,
RelationType,
@ -219,7 +218,7 @@ export class DataArgProcessor {
case FieldMetadataType.RELATION:
case FieldMetadataType.MORPH_RELATION: {
const fieldMetadataRelationSettings =
fieldMetadata.settings as FieldMetadataRelationSettings;
fieldMetadata.settings as FieldMetadataSettingsMapping['RELATION'];
if (
fieldMetadataRelationSettings.relationType ===
@ -252,7 +251,7 @@ export class DataArgProcessor {
const validatedValue = validateFilesFieldOrThrow(
value,
key,
fieldMetadata.settings as FieldMetadataFilesSettings,
fieldMetadata.settings as FieldMetadataSettingsMapping['FILES'],
);
return transformRawJsonField(validatedValue);

View file

@ -2,8 +2,8 @@ import { inspect } from 'util';
import { msg } from '@lingui/core/macro';
import { isNull } from '@sniptt/guards';
import { type FieldMetadataFilesSettings } from 'twenty-shared/types';
import { z } from 'zod';
import { type FieldMetadataSettingsMapping } from 'twenty-shared/types';
import {
CommonQueryRunnerException,
@ -24,7 +24,7 @@ export type FileItem = z.infer<typeof fileItemSchema>;
export const validateFilesFieldOrThrow = (
value: unknown,
fieldName: string,
settings: FieldMetadataFilesSettings,
settings: FieldMetadataSettingsMapping['FILES'],
): FileItem[] | null => {
if (isNull(value)) return null;

View file

@ -6,7 +6,7 @@ import {
QUERY_MAX_RECORDS_FROM_RELATION,
} from 'twenty-shared/constants';
import {
FieldMetadataRelationSettings,
FieldMetadataSettingsMapping,
FieldMetadataType,
ObjectRecord,
RelationType,
@ -256,8 +256,9 @@ export class CommonMergeManyQueryRunnerService extends CommonBaseQueryRunnerServ
const relationType =
isDryRun && fieldMetadata.type === FieldMetadataType.RELATION
? (fieldMetadata.settings as FieldMetadataRelationSettings)
?.relationType
? (
fieldMetadata.settings as FieldMetadataSettingsMapping['RELATION']
)?.relationType
: undefined;
mergedResult[fieldName] = mergeFieldValues(
@ -376,7 +377,7 @@ export class CommonMergeManyQueryRunnerService extends CommonBaseQueryRunnerServ
}
const relationSettings = field.settings as
| FieldMetadataRelationSettings
| FieldMetadataSettingsMapping['RELATION']
| undefined;
if (

View file

@ -1,7 +1,8 @@
import { type GraphQLScalarType } from 'graphql';
import { type FieldMetadataType } from 'twenty-shared/types';
import { type FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import {
type FieldMetadataType,
type FieldMetadataDefaultValue,
} from 'twenty-shared/types';
import { type GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum';

View file

@ -19,11 +19,10 @@ import {
type FieldMetadataSettings,
FieldMetadataType,
NumberDataType,
type FieldMetadataDefaultValue,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { OrderByDirectionType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/enum';
import {

View file

@ -53,7 +53,7 @@ describe('computeSchemaComponents', () => {
"description": "Object description",
"example": {
"fieldCurrency": {
"amountMicros": 284000000,
"amountMicros": "284000000",
"currencyCode": "EUR",
},
"fieldEmails": {
@ -554,7 +554,7 @@ describe('computeSchemaComponents', () => {
"description": "Object description",
"example": {
"fieldCurrency": {
"amountMicros": 253000000,
"amountMicros": "253000000",
"currencyCode": "EUR",
},
"fieldEmails": {

View file

@ -1,8 +1,10 @@
import { type OpenAPIV3_1 } from 'openapi-types';
import { FieldMetadataType } from 'twenty-shared/types';
import {
type FieldMetadataDefaultValue,
FieldMetadataType,
} from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
import { type FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { generateRandomFieldValue } from 'src/engine/core-modules/open-api/utils/generate-random-field-value.util';

View file

@ -1,10 +1,11 @@
import { faker } from '@faker-js/faker';
import { FieldMetadataType } from 'twenty-shared/types';
import {
type FieldMetadataDefaultValue,
FieldMetadataType,
} from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { type FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
@ -65,7 +66,7 @@ export const generateRandomFieldValue = ({
case FieldMetadataType.CURRENCY: {
return {
amountMicros: faker.number.int({ min: 100, max: 1_000 }) * 1_000_000,
amountMicros: `${faker.number.int({ min: 100, max: 1_000 }) * 1_000_000}`,
currencyCode: 'EUR',
};
}
@ -131,7 +132,6 @@ export const generateRandomFieldValue = ({
case FieldMetadataType.ACTOR: {
return {
source: 'MANUAL',
context: {},
name: faker.person.fullName(),
workspaceMemberId: null,
};

View file

@ -15,6 +15,7 @@ import {
ModelId,
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
import { JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
@Entity('agent')
@Index('IDX_AGENT_ID_DELETED_AT', ['id', 'deletedAt'])
@ -52,7 +53,7 @@ export class AgentEntity
// Should not be nullable
@Column({ nullable: true, type: 'jsonb', default: { type: 'text' } })
responseFormat: AgentResponseFormat;
responseFormat: JsonbProperty<AgentResponseFormat>;
@Column({ default: false })
isCustom: boolean;
@ -67,7 +68,7 @@ export class AgentEntity
deletedAt: Date | null;
@Column({ nullable: true, type: 'jsonb' })
modelConfiguration: ModelConfiguration | null;
modelConfiguration: JsonbProperty<ModelConfiguration> | null;
@Column({ type: 'text', array: true, default: '{}' })
evaluationInputs: string[];

View file

@ -10,6 +10,7 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { type JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@ -28,7 +29,7 @@ export class CronTriggerEntity
id: string;
@Column({ nullable: false, type: 'jsonb' })
settings: CronTriggerSettings;
settings: JsonbProperty<CronTriggerSettings>;
@ManyToOne(
() => ServerlessFunctionEntity,

View file

@ -10,6 +10,7 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { type JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@ -31,7 +32,7 @@ export class DatabaseEventTriggerEntity
id: string;
@Column({ nullable: false, type: 'jsonb' })
settings: DatabaseEventTriggerSettings;
settings: JsonbProperty<DatabaseEventTriggerSettings>;
@ManyToOne(
() => ServerlessFunctionEntity,

View file

@ -1,220 +0,0 @@
import {
IsArray,
IsBoolean,
IsDate,
IsNotEmpty,
IsNumber,
IsNumberString,
IsObject,
IsOptional,
IsString,
IsUUID,
Matches,
ValidateIf,
} from 'class-validator';
import { IsQuotedString } from 'src/engine/metadata-modules/field-metadata/validators/is-quoted-string.validator';
export const fieldMetadataDefaultValueFunctionName = {
UUID: 'uuid',
NOW: 'now',
} as const;
export type FieldMetadataDefaultValueFunctionNames =
(typeof fieldMetadataDefaultValueFunctionName)[keyof typeof fieldMetadataDefaultValueFunctionName];
export class FieldMetadataDefaultValueString {
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
value: string | null;
}
export class FieldMetadataDefaultValueRawJson {
@ValidateIf((_object, value) => value !== null)
@IsObject() // TODO: Should this also allow arrays?
value: object | null;
}
export class FieldMetadataDefaultValueRichTextV2 {
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
blocknote: string | null;
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
markdown: string | null;
}
export class FieldMetadataDefaultValueRichText {
@ValidateIf((_object, value) => value !== null)
@IsString()
value: string | null;
}
export class FieldMetadataDefaultValueNumber {
@ValidateIf((_object, value) => value !== null)
@IsNumber()
value: number | null;
}
export class FieldMetadataDefaultValueBoolean {
@ValidateIf((_object, value) => value !== null)
@IsBoolean()
value: boolean | null;
}
export class FieldMetadataDefaultValueStringArray {
@ValidateIf((_object, value) => value !== null)
@IsArray()
@IsQuotedString({ each: true })
value: string[] | null;
}
export class FieldMetadataDefaultValueDateTime {
@ValidateIf((_object, value) => value !== null)
@IsDate()
value: Date | null;
}
export class FieldMetadataDefaultValueDate {
@ValidateIf((_object, value) => value !== null)
@IsDate()
value: Date | null;
}
export class FieldMetadataDefaultValueCurrency {
@ValidateIf((_object, value) => value !== null)
@IsNumberString()
amountMicros: string | null;
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
currencyCode: string | null;
}
export class FieldMetadataDefaultValueFullName {
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
firstName: string | null;
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
lastName: string | null;
}
export class FieldMetadataDefaultValueUuidFunction {
@Matches(fieldMetadataDefaultValueFunctionName.UUID)
@IsNotEmpty()
value: typeof fieldMetadataDefaultValueFunctionName.UUID;
}
export class FieldMetadataDefaultValueNowFunction {
@Matches(fieldMetadataDefaultValueFunctionName.NOW)
@IsNotEmpty()
value: typeof fieldMetadataDefaultValueFunctionName.NOW;
}
export class FieldMetadataDefaultValueAddress {
@ValidateIf((_object, value) => value !== null)
@IsString()
addressStreet1: string | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
addressStreet2: string | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
addressCity: string | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
addressPostcode: string | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
addressState: string | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
addressCountry: string | null;
@ValidateIf((_object, value) => value !== null)
@IsNumber()
addressLat: number | null;
@ValidateIf((_object, value) => value !== null)
@IsNumber()
addressLng: number | null;
}
class LinkMetadata {
@IsString()
label: string;
@IsString()
url: string;
}
export class FieldMetadataDefaultValueLinks {
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
primaryLinkLabel: string | null;
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
primaryLinkUrl: string | null;
@ValidateIf((_object, value) => value !== null)
@IsArray()
secondaryLinks: LinkMetadata[] | null;
}
export class FieldMetadataDefaultActor {
@ValidateIf((_object, value) => value !== null)
@IsString()
source: string;
@ValidateIf((_object, value) => value !== null)
@IsOptional()
@IsUUID()
workspaceMemberId?: string | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
name: string;
}
export class FieldMetadataDefaultValueEmails {
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
primaryEmail: string | null;
@ValidateIf((_object, value) => value !== null)
@IsObject()
additionalEmails: object | null;
}
export class FieldMetadataDefaultValuePhones {
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
primaryPhoneNumber: string | null;
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
primaryPhoneCountryCode: string | null;
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
primaryPhoneCallingCode: string | null;
@ValidateIf((_object, value) => value !== null)
@IsObject()
additionalPhones: object | null;
}
export class FieldMetadataDefaultArray {
@ValidateIf((_object, value) => value !== null)
@IsArray()
value: string[] | null;
}

View file

@ -27,10 +27,9 @@ import {
type FieldMetadataOptions,
type FieldMetadataSettings,
FieldMetadataType,
type FieldMetadataDefaultValue,
} from 'twenty-shared/types';
import { type FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { IsValidMetadataName } from 'src/engine/decorators/metadata/is-valid-metadata-name.decorator';
import { FieldStandardOverridesDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto';

View file

@ -1,4 +1,5 @@
import {
FieldMetadataDefaultValue,
FieldMetadataOptions,
FieldMetadataSettings,
FieldMetadataType,
@ -19,8 +20,6 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { type FieldStandardOverridesDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto';
import { AssignIfIsGivenFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/assign-if-is-given-field-metadata-type.type';
import { AssignTypeIfIsMorphOrRelationFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/assign-type-if-is-morph-or-relation-field-metadata-type.type';
@ -31,6 +30,7 @@ import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities
import { ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
import { JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
// This entity is used as a reference test case for type utilities in:
// Modifying relations or properties may require updating type test expectations for Typecheck to pass.
@ -91,7 +91,7 @@ export class FieldMetadataEntity<
label: string;
@Column({ nullable: true, type: 'jsonb' })
defaultValue: FieldMetadataDefaultValue<TFieldMetadataType>;
defaultValue: JsonbProperty<FieldMetadataDefaultValue<TFieldMetadataType>>;
@Column({ nullable: true, type: 'text' })
description: string | null;
@ -100,13 +100,13 @@ export class FieldMetadataEntity<
icon: string | null;
@Column({ type: 'jsonb', nullable: true })
standardOverrides: FieldStandardOverridesDTO | null;
standardOverrides: JsonbProperty<FieldStandardOverridesDTO> | null;
@Column('jsonb', { nullable: true })
options: FieldMetadataOptions<TFieldMetadataType>;
options: JsonbProperty<FieldMetadataOptions<TFieldMetadataType>>;
@Column('jsonb', { nullable: true })
settings: FieldMetadataSettings<TFieldMetadataType>;
settings: JsonbProperty<FieldMetadataSettings<TFieldMetadataType>>;
@Column({ default: false })
isCustom: boolean;

View file

@ -1,89 +0,0 @@
import { type FieldMetadataType, type IsExactly } from 'twenty-shared/types';
import {
type FieldMetadataDefaultActor,
type FieldMetadataDefaultArray,
type FieldMetadataDefaultValueAddress,
type FieldMetadataDefaultValueBoolean,
type FieldMetadataDefaultValueCurrency,
type FieldMetadataDefaultValueDateTime,
type FieldMetadataDefaultValueEmails,
type FieldMetadataDefaultValueFullName,
type FieldMetadataDefaultValueLinks,
type FieldMetadataDefaultValueNowFunction,
type FieldMetadataDefaultValueNumber,
type FieldMetadataDefaultValuePhones,
type FieldMetadataDefaultValueRawJson,
type FieldMetadataDefaultValueRichText,
type FieldMetadataDefaultValueString,
type FieldMetadataDefaultValueStringArray,
type FieldMetadataDefaultValueUuidFunction,
} from 'src/engine/metadata-modules/field-metadata/dtos/default-value.input';
type ExtractValueType<T> = T extends { value: infer V } ? V : T;
type UnionOfValues<T> = T[keyof T];
type FieldMetadataDefaultValueMapping = {
[FieldMetadataType.UUID]:
| FieldMetadataDefaultValueString
| FieldMetadataDefaultValueUuidFunction;
[FieldMetadataType.TEXT]: FieldMetadataDefaultValueString;
[FieldMetadataType.PHONES]: FieldMetadataDefaultValuePhones;
[FieldMetadataType.EMAILS]: FieldMetadataDefaultValueEmails;
[FieldMetadataType.DATE_TIME]:
| FieldMetadataDefaultValueDateTime
| FieldMetadataDefaultValueNowFunction;
[FieldMetadataType.DATE]:
| FieldMetadataDefaultValueDateTime
| FieldMetadataDefaultValueNowFunction;
[FieldMetadataType.BOOLEAN]: FieldMetadataDefaultValueBoolean;
[FieldMetadataType.NUMBER]: FieldMetadataDefaultValueNumber;
[FieldMetadataType.POSITION]: FieldMetadataDefaultValueNumber;
[FieldMetadataType.NUMERIC]: FieldMetadataDefaultValueString;
[FieldMetadataType.LINKS]: FieldMetadataDefaultValueLinks;
[FieldMetadataType.CURRENCY]: FieldMetadataDefaultValueCurrency;
[FieldMetadataType.FULL_NAME]: FieldMetadataDefaultValueFullName;
[FieldMetadataType.ADDRESS]: FieldMetadataDefaultValueAddress;
[FieldMetadataType.RATING]: FieldMetadataDefaultValueString;
[FieldMetadataType.SELECT]: FieldMetadataDefaultValueString;
[FieldMetadataType.MULTI_SELECT]: FieldMetadataDefaultValueStringArray;
[FieldMetadataType.RAW_JSON]: FieldMetadataDefaultValueRawJson;
[FieldMetadataType.RICH_TEXT]: FieldMetadataDefaultValueRichText;
[FieldMetadataType.ACTOR]: FieldMetadataDefaultActor;
[FieldMetadataType.ARRAY]: FieldMetadataDefaultArray;
};
export type FieldMetadataClassValidation =
UnionOfValues<FieldMetadataDefaultValueMapping>;
export type FieldMetadataFunctionDefaultValue = ExtractValueType<
FieldMetadataDefaultValueUuidFunction | FieldMetadataDefaultValueNowFunction
>;
export type FieldMetadataDefaultValueForType<
T extends keyof FieldMetadataDefaultValueMapping,
> = ExtractValueType<FieldMetadataDefaultValueMapping[T]> | null;
export type FieldMetadataDefaultValueForAnyType = ExtractValueType<
UnionOfValues<FieldMetadataDefaultValueMapping>
> | null;
export type FieldMetadataDefaultValue<
T extends FieldMetadataType = FieldMetadataType,
> =
IsExactly<T, FieldMetadataType> extends true
? FieldMetadataDefaultValueForAnyType | null // Could be improved to be | unknown
: T extends keyof FieldMetadataDefaultValueMapping
? FieldMetadataDefaultValueForType<T>
: never | null;
type FieldMetadataDefaultValueExtractedTypes = {
[K in keyof FieldMetadataDefaultValueMapping]: ExtractValueType<
FieldMetadataDefaultValueMapping[K]
>;
};
export type FieldMetadataDefaultSerializableValue =
| FieldMetadataDefaultValueExtractedTypes[keyof FieldMetadataDefaultValueExtractedTypes]
| null;

View file

@ -1,22 +1,16 @@
import { type Expect, type HasAllProperties } from 'twenty-shared/testing';
import {
type FieldMetadataMultiItemSettings,
type AllFieldMetadataSettings,
type FieldMetadataDefaultValueForAnyType,
type FieldMetadataDefaultValueMapping,
type FieldMetadataOptionForAnyType,
type FieldMetadataSettingsMapping,
type FieldMetadataType,
type NullablePartial,
type AllFieldMetadataSettings,
type FieldMetadataDateSettings,
type FieldMetadataDateTimeSettings,
type FieldMetadataNumberSettings,
type FieldMetadataRelationSettings,
type FieldMetadataTextSettings,
} from 'twenty-shared/types';
import { type Relation as TypeOrmRelation } from 'typeorm';
import {
type FieldMetadataDefaultValueForAnyType,
type FieldMetadataDefaultValueForType,
} from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { type JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
import {
type FieldMetadataComplexOption,
type FieldMetadataDefaultOption,
@ -138,69 +132,109 @@ type SettingsAssertions = [
Expect<
HasAllProperties<
TextFieldMetadata,
{ settings: FieldMetadataTextSettings | null }
{
settings: JsonbProperty<
FieldMetadataSettingsMapping[FieldMetadataType.TEXT]
>;
}
>
>,
Expect<
HasAllProperties<
NumberFieldMetadata,
{ settings: FieldMetadataNumberSettings | null }
{
settings: JsonbProperty<
FieldMetadataSettingsMapping[FieldMetadataType.NUMBER]
>;
}
>
>,
Expect<
HasAllProperties<
DateFieldMetadata,
{ settings: FieldMetadataDateSettings | null }
{
settings: JsonbProperty<
FieldMetadataSettingsMapping[FieldMetadataType.DATE]
>;
}
>
>,
Expect<
HasAllProperties<
DateTimeFieldMetadata,
{ settings: FieldMetadataDateTimeSettings | null }
{
settings: JsonbProperty<
FieldMetadataSettingsMapping[FieldMetadataType.DATE_TIME]
>;
}
>
>,
Expect<
HasAllProperties<
ArrayFieldMetadata,
{ settings: FieldMetadataMultiItemSettings | null }
{
settings: JsonbProperty<
FieldMetadataSettingsMapping[FieldMetadataType.ARRAY]
>;
}
>
>,
Expect<
HasAllProperties<
PhonesFieldMetadata,
{ settings: FieldMetadataMultiItemSettings | null }
{
settings: JsonbProperty<
FieldMetadataSettingsMapping[FieldMetadataType.PHONES]
>;
}
>
>,
Expect<
HasAllProperties<
EmailsFieldMetadata,
{ settings: FieldMetadataMultiItemSettings | null }
{
settings: JsonbProperty<
FieldMetadataSettingsMapping[FieldMetadataType.EMAILS]
>;
}
>
>,
Expect<
HasAllProperties<
LinksFieldMetadata,
{ settings: FieldMetadataMultiItemSettings | null }
{
settings: JsonbProperty<
FieldMetadataSettingsMapping[FieldMetadataType.LINKS]
>;
}
>
>,
Expect<
HasAllProperties<
RelationFieldMetadata,
{ settings: FieldMetadataRelationSettings }
{
settings: JsonbProperty<
FieldMetadataSettingsMapping[FieldMetadataType.RELATION]
>;
}
>
>,
Expect<
HasAllProperties<
MorphRelationFieldMetadata,
{ settings: FieldMetadataRelationSettings }
{
settings: JsonbProperty<
FieldMetadataSettingsMapping[FieldMetadataType.MORPH_RELATION]
>;
}
>
>,
Expect<
HasAllProperties<
AbstractFieldMetadata,
{ settings: AllFieldMetadataSettings | null }
{ settings: JsonbProperty<AllFieldMetadataSettings> | null }
>
>,
];
@ -210,20 +244,30 @@ type DefaultValueAssertions = [
Expect<
HasAllProperties<
UUIDFieldMetadata,
{ defaultValue: FieldMetadataDefaultValueForType<FieldMetadataType.UUID> }
{
defaultValue: JsonbProperty<
FieldMetadataDefaultValueMapping[FieldMetadataType.UUID]
>;
}
>
>,
Expect<
HasAllProperties<
TextFieldMetadata,
{ defaultValue: FieldMetadataDefaultValueForType<FieldMetadataType.TEXT> }
{
defaultValue: JsonbProperty<
FieldMetadataDefaultValueMapping[FieldMetadataType.TEXT]
>;
}
>
>,
Expect<
HasAllProperties<
NumberFieldMetadata,
{
defaultValue: FieldMetadataDefaultValueForType<FieldMetadataType.NUMBER>;
defaultValue: JsonbProperty<
FieldMetadataDefaultValueMapping[FieldMetadataType.NUMBER]
>;
}
>
>,
@ -231,21 +275,29 @@ type DefaultValueAssertions = [
HasAllProperties<
BooleanFieldMetadata,
{
defaultValue: FieldMetadataDefaultValueForType<FieldMetadataType.BOOLEAN>;
defaultValue: JsonbProperty<
FieldMetadataDefaultValueMapping[FieldMetadataType.BOOLEAN]
>;
}
>
>,
Expect<
HasAllProperties<
DateFieldMetadata,
{ defaultValue: FieldMetadataDefaultValueForType<FieldMetadataType.DATE> }
{
defaultValue: JsonbProperty<
FieldMetadataDefaultValueMapping[FieldMetadataType.DATE]
>;
}
>
>,
Expect<
HasAllProperties<
DateTimeFieldMetadata,
{
defaultValue: FieldMetadataDefaultValueForType<FieldMetadataType.DATE_TIME>;
defaultValue: JsonbProperty<
FieldMetadataDefaultValueMapping[FieldMetadataType.DATE_TIME]
>;
}
>
>,
@ -253,7 +305,9 @@ type DefaultValueAssertions = [
HasAllProperties<
CurrencyFieldMetadata,
{
defaultValue: FieldMetadataDefaultValueForType<FieldMetadataType.CURRENCY>;
defaultValue: JsonbProperty<
FieldMetadataDefaultValueMapping[FieldMetadataType.CURRENCY]
>;
}
>
>,
@ -261,7 +315,9 @@ type DefaultValueAssertions = [
HasAllProperties<
FullNameFieldMetadata,
{
defaultValue: FieldMetadataDefaultValueForType<FieldMetadataType.FULL_NAME>;
defaultValue: JsonbProperty<
FieldMetadataDefaultValueMapping[FieldMetadataType.FULL_NAME]
>;
}
>
>,
@ -269,7 +325,9 @@ type DefaultValueAssertions = [
HasAllProperties<
RatingFieldMetadata,
{
defaultValue: FieldMetadataDefaultValueForType<FieldMetadataType.RATING>;
defaultValue: JsonbProperty<
FieldMetadataDefaultValueMapping[FieldMetadataType.RATING]
>;
}
>
>,
@ -277,7 +335,9 @@ type DefaultValueAssertions = [
HasAllProperties<
SelectFieldMetadata,
{
defaultValue: FieldMetadataDefaultValueForType<FieldMetadataType.SELECT>;
defaultValue: JsonbProperty<
FieldMetadataDefaultValueMapping[FieldMetadataType.SELECT]
>;
}
>
>,
@ -285,7 +345,9 @@ type DefaultValueAssertions = [
HasAllProperties<
MultiSelectFieldMetadata,
{
defaultValue: FieldMetadataDefaultValueForType<FieldMetadataType.MULTI_SELECT>;
defaultValue: JsonbProperty<
FieldMetadataDefaultValueMapping[FieldMetadataType.MULTI_SELECT]
>;
}
>
>,
@ -293,7 +355,9 @@ type DefaultValueAssertions = [
HasAllProperties<
PositionFieldMetadata,
{
defaultValue: FieldMetadataDefaultValueForType<FieldMetadataType.POSITION>;
defaultValue: JsonbProperty<
FieldMetadataDefaultValueMapping[FieldMetadataType.POSITION]
>;
}
>
>,
@ -301,7 +365,9 @@ type DefaultValueAssertions = [
HasAllProperties<
RawJsonFieldMetadata,
{
defaultValue: FieldMetadataDefaultValueForType<FieldMetadataType.RAW_JSON>;
defaultValue: JsonbProperty<
FieldMetadataDefaultValueMapping[FieldMetadataType.RAW_JSON]
>;
}
>
>,
@ -309,7 +375,9 @@ type DefaultValueAssertions = [
HasAllProperties<
RichTextFieldMetadata,
{
defaultValue: FieldMetadataDefaultValueForType<FieldMetadataType.RICH_TEXT>;
defaultValue: JsonbProperty<
FieldMetadataDefaultValueMapping[FieldMetadataType.RICH_TEXT]
>;
}
>
>,
@ -317,7 +385,9 @@ type DefaultValueAssertions = [
HasAllProperties<
ActorFieldMetadata,
{
defaultValue: FieldMetadataDefaultValueForType<FieldMetadataType.ACTOR>;
defaultValue: JsonbProperty<
FieldMetadataDefaultValueMapping[FieldMetadataType.ACTOR]
>;
}
>
>,
@ -325,7 +395,9 @@ type DefaultValueAssertions = [
HasAllProperties<
ArrayFieldMetadata,
{
defaultValue: FieldMetadataDefaultValueForType<FieldMetadataType.ARRAY>;
defaultValue: JsonbProperty<
FieldMetadataDefaultValueMapping[FieldMetadataType.ARRAY]
>;
}
>
>,
@ -333,7 +405,9 @@ type DefaultValueAssertions = [
HasAllProperties<
PhonesFieldMetadata,
{
defaultValue: FieldMetadataDefaultValueForType<FieldMetadataType.PHONES>;
defaultValue: JsonbProperty<
FieldMetadataDefaultValueMapping[FieldMetadataType.PHONES]
>;
}
>
>,
@ -341,7 +415,9 @@ type DefaultValueAssertions = [
HasAllProperties<
EmailsFieldMetadata,
{
defaultValue: FieldMetadataDefaultValueForType<FieldMetadataType.EMAILS>;
defaultValue: JsonbProperty<
FieldMetadataDefaultValueMapping[FieldMetadataType.EMAILS]
>;
}
>
>,
@ -349,7 +425,9 @@ type DefaultValueAssertions = [
HasAllProperties<
LinksFieldMetadata,
{
defaultValue: FieldMetadataDefaultValueForType<FieldMetadataType.LINKS>;
defaultValue: JsonbProperty<
FieldMetadataDefaultValueMapping[FieldMetadataType.LINKS]
>;
}
>
>,
@ -364,7 +442,9 @@ type DefaultValueAssertions = [
Expect<
HasAllProperties<
AbstractFieldMetadata,
{ defaultValue: FieldMetadataDefaultValueForAnyType | null }
{
defaultValue: JsonbProperty<FieldMetadataDefaultValueForAnyType | null>;
}
>
>,
];
@ -378,19 +458,19 @@ type OptionsAssertions = [
Expect<
HasAllProperties<
RatingFieldMetadata,
{ options: FieldMetadataDefaultOption[] }
{ options: JsonbProperty<FieldMetadataDefaultOption[]> }
>
>,
Expect<
HasAllProperties<
SelectFieldMetadata,
{ options: FieldMetadataComplexOption[] }
{ options: JsonbProperty<FieldMetadataComplexOption[]> }
>
>,
Expect<
HasAllProperties<
MultiSelectFieldMetadata,
{ options: FieldMetadataComplexOption[] }
{ options: JsonbProperty<FieldMetadataComplexOption[]> }
>
>,
@ -417,9 +497,7 @@ type OptionsAssertions = [
HasAllProperties<
AbstractFieldMetadata,
{
options:
| null
| (FieldMetadataDefaultOption[] | FieldMetadataComplexOption[]);
options: JsonbProperty<FieldMetadataOptionForAnyType>;
}
>
>,

View file

@ -1,95 +0,0 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { validateDefaultValueForType } from 'src/engine/metadata-modules/field-metadata/utils/validate-default-value-for-type.util';
describe('validateDefaultValueForType', () => {
it('should return true for null defaultValue', () => {
expect(
validateDefaultValueForType(FieldMetadataType.TEXT, null).isValid,
).toBe(true);
});
// Dynamic default values
it('should validate uuid dynamic default value for UUID type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.UUID, 'uuid').isValid,
).toBe(true);
});
it('should validate now dynamic default value for DATE_TIME type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.DATE_TIME, 'now').isValid,
).toBe(true);
});
it('should return false for mismatched dynamic default value', () => {
expect(
validateDefaultValueForType(FieldMetadataType.UUID, 'now').isValid,
).toBe(false);
});
// Static default values
it('should validate string default value for TEXT type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.TEXT, "'test'").isValid,
).toBe(true);
});
it('should return false for invalid string default value for TEXT type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.TEXT, 123).isValid,
).toBe(false);
});
it('should validate number default value for NUMBER type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.NUMBER, 100).isValid,
).toBe(true);
});
it('should return false for invalid number default value for NUMBER type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.NUMBER, '100').isValid,
).toBe(false);
});
it('should validate boolean default value for BOOLEAN type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.BOOLEAN, true).isValid,
).toBe(true);
});
it('should return false for invalid boolean default value for BOOLEAN type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.BOOLEAN, 'true').isValid,
).toBe(false);
});
// CURRENCY type
it('should validate CURRENCY default value', () => {
expect(
validateDefaultValueForType(FieldMetadataType.CURRENCY, {
amountMicros: '100',
currencyCode: "'USD'",
}).isValid,
).toBe(true);
});
it('should return false for invalid CURRENCY default value', () => {
expect(
validateDefaultValueForType(
// @ts-expect-error Just for testing purposes
{ amountMicros: 100, currencyCode: "'USD'" },
FieldMetadataType.CURRENCY,
).isValid,
).toBe(false);
});
// Unknown type
it('should return false for unknown type', () => {
expect(
validateDefaultValueForType('unknown' as FieldMetadataType, "'test'")
.isValid,
).toBe(false);
});
});

View file

@ -1,6 +1,8 @@
import { FieldActorSource, FieldMetadataType } from 'twenty-shared/types';
import { type FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import {
FieldActorSource,
type FieldMetadataDefaultValue,
FieldMetadataType,
} from 'twenty-shared/types';
// No need to refactor as unused in workspace migration v2
export function generateDefaultValue(

View file

@ -1,15 +1,12 @@
import {
type FieldMetadataDefaultSerializableValue,
type FieldMetadataFunctionDefaultValue,
} from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import {
type FieldMetadataDefaultValueForAnyType,
type FieldMetadataDefaultValueFunctionNames,
type FieldMetadataFunctionDefaultValue,
fieldMetadataDefaultValueFunctionName,
} from 'src/engine/metadata-modules/field-metadata/dtos/default-value.input';
} from 'twenty-shared/types';
export const isFunctionDefaultValue = (
defaultValue: FieldMetadataDefaultSerializableValue,
defaultValue: FieldMetadataDefaultValueForAnyType,
): defaultValue is FieldMetadataFunctionDefaultValue => {
return (
typeof defaultValue === 'string' &&

View file

@ -1,4 +1,4 @@
import { type FieldMetadataDefaultSerializableValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { type FieldMetadataDefaultValueForAnyType } from 'twenty-shared/types';
import {
FieldMetadataException,
@ -8,7 +8,7 @@ import { isFunctionDefaultValue } from 'src/engine/metadata-modules/field-metada
import { serializeFunctionDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/serialize-function-default-value.util';
export const serializeDefaultValue = (
defaultValue?: FieldMetadataDefaultSerializableValue,
defaultValue?: FieldMetadataDefaultValueForAnyType,
) => {
if (defaultValue === undefined || defaultValue === null) {
return null;

View file

@ -1,4 +1,4 @@
import { type FieldMetadataFunctionDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { type FieldMetadataFunctionDefaultValue } from 'twenty-shared/types';
export const serializeFunctionDefaultValue = (
defaultValue?: FieldMetadataFunctionDefaultValue,

View file

@ -1,7 +1,7 @@
import { type FieldMetadataDefaultSerializableValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { type FieldMetadataDefaultValueForAnyType } from 'twenty-shared/types';
export const unserializeDefaultValue = (
serializedDefaultValue: FieldMetadataDefaultSerializableValue,
serializedDefaultValue: FieldMetadataDefaultValueForAnyType,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): any => {
if (serializedDefaultValue === undefined || serializedDefaultValue === null) {

View file

@ -1,118 +0,0 @@
import { plainToInstance } from 'class-transformer';
import { type ValidationError, validateSync } from 'class-validator';
import { FieldMetadataType } from 'twenty-shared/types';
import {
type FieldMetadataClassValidation,
type FieldMetadataDefaultValue,
} from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import {
FieldMetadataDefaultActor,
FieldMetadataDefaultValueAddress,
FieldMetadataDefaultValueBoolean,
FieldMetadataDefaultValueCurrency,
FieldMetadataDefaultValueDate,
FieldMetadataDefaultValueDateTime,
FieldMetadataDefaultValueEmails,
FieldMetadataDefaultValueFullName,
FieldMetadataDefaultValueLinks,
FieldMetadataDefaultValueNowFunction,
FieldMetadataDefaultValueNumber,
FieldMetadataDefaultValuePhones,
FieldMetadataDefaultValueRawJson,
FieldMetadataDefaultValueRichTextV2,
FieldMetadataDefaultValueString,
FieldMetadataDefaultValueStringArray,
FieldMetadataDefaultValueUuidFunction,
} from 'src/engine/metadata-modules/field-metadata/dtos/default-value.input';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
export const defaultValueValidatorsMap = {
[FieldMetadataType.UUID]: [
FieldMetadataDefaultValueString,
FieldMetadataDefaultValueUuidFunction,
],
[FieldMetadataType.TEXT]: [FieldMetadataDefaultValueString],
[FieldMetadataType.DATE_TIME]: [
FieldMetadataDefaultValueDateTime,
FieldMetadataDefaultValueNowFunction,
],
[FieldMetadataType.DATE]: [FieldMetadataDefaultValueDate],
[FieldMetadataType.BOOLEAN]: [FieldMetadataDefaultValueBoolean],
[FieldMetadataType.NUMBER]: [FieldMetadataDefaultValueNumber],
[FieldMetadataType.NUMERIC]: [FieldMetadataDefaultValueString],
[FieldMetadataType.CURRENCY]: [FieldMetadataDefaultValueCurrency],
[FieldMetadataType.FULL_NAME]: [FieldMetadataDefaultValueFullName],
[FieldMetadataType.RATING]: [FieldMetadataDefaultValueString],
[FieldMetadataType.SELECT]: [FieldMetadataDefaultValueString],
[FieldMetadataType.MULTI_SELECT]: [FieldMetadataDefaultValueStringArray],
[FieldMetadataType.ADDRESS]: [FieldMetadataDefaultValueAddress],
[FieldMetadataType.RICH_TEXT_V2]: [FieldMetadataDefaultValueRichTextV2],
[FieldMetadataType.RICH_TEXT]: [FieldMetadataDefaultValueString],
[FieldMetadataType.RAW_JSON]: [FieldMetadataDefaultValueRawJson],
[FieldMetadataType.LINKS]: [FieldMetadataDefaultValueLinks],
[FieldMetadataType.ACTOR]: [FieldMetadataDefaultActor],
[FieldMetadataType.EMAILS]: [FieldMetadataDefaultValueEmails],
[FieldMetadataType.PHONES]: [FieldMetadataDefaultValuePhones],
};
type ValidationResult = {
isValid: boolean;
errors: ValidationError[];
};
export const validateDefaultValueForType = (
type: FieldMetadataType,
defaultValue: FieldMetadataDefaultValue,
): ValidationResult => {
if (defaultValue === null) {
return {
isValid: true,
errors: [],
};
}
// @ts-expect-error legacy noImplicitAny
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const validators = defaultValueValidatorsMap[type] as any[];
if (!validators) {
return {
isValid: false,
errors: [],
};
}
const validationResults = validators.map((validator) => {
const computedDefaultValue = isCompositeFieldMetadataType(type)
? defaultValue
: { value: defaultValue };
const defaultValueInstance = plainToInstance<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
any,
FieldMetadataClassValidation
>(validator, computedDefaultValue as FieldMetadataClassValidation);
const errors = validateSync(defaultValueInstance, {
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
});
const isValid = errors.length === 0;
return {
isValid,
errors,
};
});
const isValid = validationResults.some((result) => result.isValid);
return {
isValid,
errors: validationResults.flatMap((result) => result.errors),
};
};

View file

@ -1,5 +1,3 @@
import { type Relation } from 'typeorm';
import { type AllNonWorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/all-non-workspace-related-entity.type';
import { type WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
@ -10,7 +8,7 @@ export type ExtractEntityManyToOneEntityRelationProperties<
{
[P in keyof T]: [NonNullable<T[P]>] extends [never]
? never
: NonNullable<T[P]> extends Relation<TTarget>
: NonNullable<T[P]> extends TTarget
? P
: never;
}[keyof T]

View file

@ -1,5 +1,3 @@
import { type Relation } from 'typeorm';
import { type AllNonWorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/all-non-workspace-related-entity.type';
import { type WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
@ -9,7 +7,7 @@ export type ExtractEntityOneToManyEntityRelationProperties<
> = NonNullable<
{
[P in keyof T]: NonNullable<T[P]> extends Array<infer U>
? U extends Relation<TTarget>
? U extends TTarget
? P
: never
: never;

View file

@ -1,6 +1,6 @@
import { type EachTestingContext } from 'twenty-shared/testing';
import {
type FieldMetadataRelationSettings,
type FieldMetadataSettingsMapping,
FieldMetadataType,
RelationOnDeleteAction,
RelationType,
@ -285,9 +285,9 @@ describe('generate Morph Or Relation Flat Field Metadata Pair test suite', () =>
input.targetFlatObjectMetadata.id,
);
const sourceSettings =
sourceFieldMetadata.settings as FieldMetadataRelationSettings;
sourceFieldMetadata.settings as FieldMetadataSettingsMapping['RELATION'];
const targetSettings =
targetFieldMetadata.settings as FieldMetadataRelationSettings;
targetFieldMetadata.settings as FieldMetadataSettingsMapping['RELATION'];
expect(sourceSettings.relationType).toBe(expectedSourceRelationType);

View file

@ -11,6 +11,7 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { type JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
import { type WorkspaceEntityDuplicateCriteria } from 'src/engine/api/graphql/workspace-query-builder/types/workspace-entity-duplicate-criteria.type';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
@ -63,7 +64,7 @@ export class ObjectMetadataEntity
icon: string | null;
@Column({ type: 'jsonb', nullable: true })
standardOverrides: ObjectStandardOverridesDTO | null;
standardOverrides: JsonbProperty<ObjectStandardOverridesDTO> | null;
/**
* @deprecated
@ -93,7 +94,7 @@ export class ObjectMetadataEntity
isSearchable: boolean;
@Column({ type: 'jsonb', nullable: true })
duplicateCriteria: WorkspaceEntityDuplicateCriteria[] | null;
duplicateCriteria: JsonbProperty<WorkspaceEntityDuplicateCriteria[]> | null;
@Column({ nullable: true, type: 'varchar' })
shortcut: string | null;

View file

@ -17,6 +17,7 @@ import {
} from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
import { CalendarStartDay } from 'twenty-shared/constants';
import { SerializedRelation } from 'twenty-shared/types';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ -37,7 +38,7 @@ export class AggregateChartConfigurationDTO
@Field(() => UUIDScalarType)
@IsUUID()
@IsNotEmpty()
aggregateFieldMetadataId: string;
aggregateFieldMetadataId: SerializedRelation;
@Field(() => AggregateOperations)
@IsEnum(AggregateOperations)

View file

@ -17,6 +17,7 @@ import {
} from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
import { CalendarStartDay } from 'twenty-shared/constants';
import { SerializedRelation } from 'twenty-shared/types';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ -41,7 +42,7 @@ export class BarChartConfigurationDTO
@Field(() => UUIDScalarType)
@IsUUID()
@IsNotEmpty()
aggregateFieldMetadataId: string;
aggregateFieldMetadataId: SerializedRelation;
@Field(() => AggregateOperations)
@IsEnum(AggregateOperations)
@ -51,7 +52,7 @@ export class BarChartConfigurationDTO
@Field(() => UUIDScalarType)
@IsUUID()
@IsNotEmpty()
primaryAxisGroupByFieldMetadataId: string;
primaryAxisGroupByFieldMetadataId: SerializedRelation;
@Field(() => String, { nullable: true })
@IsString()
@ -80,7 +81,7 @@ export class BarChartConfigurationDTO
@Field(() => UUIDScalarType, { nullable: true })
@IsUUID()
@IsOptional()
secondaryAxisGroupByFieldMetadataId?: string;
secondaryAxisGroupByFieldMetadataId?: SerializedRelation;
@Field(() => String, { nullable: true })
@IsString()

View file

@ -15,6 +15,7 @@ import {
} from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
import { CalendarStartDay } from 'twenty-shared/constants';
import { SerializedRelation } from 'twenty-shared/types';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ -34,7 +35,7 @@ export class GaugeChartConfigurationDTO
@Field(() => UUIDScalarType)
@IsUUID()
@IsNotEmpty()
aggregateFieldMetadataId: string;
aggregateFieldMetadataId: SerializedRelation;
@Field(() => AggregateOperations)
@IsEnum(AggregateOperations)

View file

@ -17,6 +17,7 @@ import {
} from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
import { CalendarStartDay } from 'twenty-shared/constants';
import { SerializedRelation } from 'twenty-shared/types';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ -39,7 +40,7 @@ export class LineChartConfigurationDTO
@Field(() => UUIDScalarType)
@IsUUID()
@IsNotEmpty()
aggregateFieldMetadataId: string;
aggregateFieldMetadataId: SerializedRelation;
@Field(() => AggregateOperations)
@IsEnum(AggregateOperations)
@ -49,7 +50,7 @@ export class LineChartConfigurationDTO
@Field(() => UUIDScalarType)
@IsUUID()
@IsNotEmpty()
primaryAxisGroupByFieldMetadataId: string;
primaryAxisGroupByFieldMetadataId: SerializedRelation;
@Field(() => String, { nullable: true })
@IsString()
@ -81,7 +82,7 @@ export class LineChartConfigurationDTO
@Field(() => UUIDScalarType, { nullable: true })
@IsUUID()
@IsOptional()
secondaryAxisGroupByFieldMetadataId?: string;
secondaryAxisGroupByFieldMetadataId?: SerializedRelation;
@Field(() => String, { nullable: true })
@IsString()

View file

@ -16,6 +16,7 @@ import {
} from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
import { CalendarStartDay } from 'twenty-shared/constants';
import { SerializedRelation } from 'twenty-shared/types';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ -37,7 +38,7 @@ export class PieChartConfigurationDTO
@Field(() => UUIDScalarType)
@IsUUID()
@IsNotEmpty()
aggregateFieldMetadataId: string;
aggregateFieldMetadataId: SerializedRelation;
@Field(() => AggregateOperations)
@IsEnum(AggregateOperations)
@ -47,7 +48,7 @@ export class PieChartConfigurationDTO
@Field(() => UUIDScalarType)
@IsUUID()
@IsNotEmpty()
groupByFieldMetadataId: string;
groupByFieldMetadataId: SerializedRelation;
@Field(() => String, { nullable: true })
@IsString()

View file

@ -1,6 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
import { SerializedRelation } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ -9,7 +10,7 @@ export class RatioAggregateConfigDTO {
@Field(() => UUIDScalarType)
@IsUUID()
@IsNotEmpty()
fieldMetadataId: string;
fieldMetadataId: SerializedRelation;
@Field(() => String)
@IsString()

View file

@ -20,6 +20,7 @@ import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums
import { type GridPosition } from 'src/engine/metadata-modules/page-layout-widget/types/grid-position.type';
import { PageLayoutWidgetConfigurationTypeSettings } from 'src/engine/metadata-modules/page-layout-widget/types/page-layout-widget-configuration.type';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
import { type JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
@Entity({ name: 'pageLayoutWidget', schema: 'core' })
@ObjectType('PageLayoutWidget')
@ -70,10 +71,12 @@ export class PageLayoutWidgetEntity<
objectMetadata: Relation<ObjectMetadataEntity> | null;
@Column({ type: 'jsonb', nullable: false })
gridPosition: GridPosition;
gridPosition: JsonbProperty<GridPosition>;
@Column({ type: 'jsonb', nullable: false })
configuration: PageLayoutWidgetConfigurationTypeSettings<TWidgetConfigurationType>;
configuration: JsonbProperty<
PageLayoutWidgetConfigurationTypeSettings<TWidgetConfigurationType>
>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;

View file

@ -12,6 +12,7 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { type JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@ -44,7 +45,7 @@ export class RouteTriggerEntity
httpMethod: HTTPMethod;
@Column({ nullable: false, type: 'jsonb', default: [] })
forwardedRequestHeaders: string[];
forwardedRequestHeaders: JsonbProperty<string[]>;
@ManyToOne(
() => ServerlessFunctionEntity,

View file

@ -23,6 +23,7 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { RowLevelPermissionPredicateGroupEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate-group.entity';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
import { JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
@Entity({ name: 'rowLevelPermissionPredicate', schema: 'core' })
@Index('IDX_RLPP_WORKSPACE_ID_ROLE_ID_OBJECT_METADATA_ID', [
@ -71,7 +72,7 @@ export class RowLevelPermissionPredicateEntity
operand: RowLevelPermissionPredicateOperand;
@Column({ nullable: true, type: 'jsonb' })
value: RowLevelPermissionPredicateValue | null;
value: JsonbProperty<RowLevelPermissionPredicateValue> | null;
@Column({ nullable: true, type: 'text', default: null })
subFieldName: string | null;

View file

@ -13,6 +13,7 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { type JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
import { CronTriggerEntity } from 'src/engine/metadata-modules/cron-trigger/entities/cron-trigger.entity';
import { DatabaseEventTriggerEntity } from 'src/engine/metadata-modules/database-event-trigger/entities/database-event-trigger.entity';
import { RouteTriggerEntity } from 'src/engine/metadata-modules/route-trigger/route-trigger.entity';
@ -59,7 +60,7 @@ export class ServerlessFunctionEntity
latestVersion: string | null;
@Column({ nullable: false, type: 'jsonb', default: [] })
publishedVersions: string[];
publishedVersions: JsonbProperty<string[]>;
@Column({ nullable: false, default: ServerlessFunctionRuntime.NODE22 })
runtime: ServerlessFunctionRuntime;
@ -72,7 +73,7 @@ export class ServerlessFunctionEntity
checksum: string | null;
@Column({ nullable: true, type: 'jsonb' })
toolInputSchema: object | null;
toolInputSchema: JsonbProperty<object> | null;
@Column({ nullable: false, default: false })
isTool: boolean;

View file

@ -12,6 +12,7 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { type JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ViewFilterGroupEntity } from 'src/engine/metadata-modules/view-filter-group/entities/view-filter-group.entity';
import { type ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type';
@ -47,7 +48,7 @@ export class ViewFilterEntity
operand: ViewFilterOperand;
@Column({ nullable: false, type: 'jsonb' })
value: ViewFilterValue;
value: JsonbProperty<ViewFilterValue>;
@Column({ nullable: true, type: 'uuid' })
viewFilterGroupId: string | null;

View file

@ -9,6 +9,4 @@ export type ViewFilterValue =
| boolean
| number
| RelationFilterValue
| Record<string, unknown>
| null
| undefined;
| Record<string, unknown>;

View file

@ -1,10 +1,10 @@
import {
type FieldMetadataType,
type FieldMetadataSettings,
type FieldMetadataOptions,
type FieldMetadataSettings,
type FieldMetadataType,
type FieldMetadataDefaultValue,
} from 'twenty-shared/types';
import { type FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { type Gate } from 'src/engine/twenty-orm/interfaces/gate.interface';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';

View file

@ -1,12 +1,12 @@
import {
type FieldMetadataRelationSettings,
type FieldMetadataSettingsMapping,
RelationType,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const formatColumnNameForRelationField = (
fieldName: string,
fieldMetadataSettings: FieldMetadataRelationSettings,
fieldMetadataSettings: FieldMetadataSettingsMapping['RELATION'],
): string => {
if (fieldMetadataSettings.relationType === RelationType.ONE_TO_MANY) {
throw new Error('No column exists for one to many relation fields');

View file

@ -0,0 +1,71 @@
import { type Equal, type Expect } from 'twenty-shared/testing';
import { type ExtractJsonbProperties } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/extract-jsonb-properties.type';
import { type JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
type EmptyObject = {};
type TestedRecord = {
// Non-JsonbProperty fields
plainString: string;
plainNumber: number;
plainObject: EmptyObject;
plainObjectNullable: EmptyObject | null;
plainArray: string[];
plainUnknown: unknown;
jsonbString: JsonbProperty<string>;
jsonbPlainUnknown: JsonbProperty<unknown>;
jsonbNumber: JsonbProperty<number>;
jsonbull: JsonbProperty<null>;
// JsonbProperty fields - should be extracted
jsonbPlainObject: JsonbProperty<EmptyObject>;
jsonbPlainArray: JsonbProperty<string[]>;
jsonbPlainObjectNullable: JsonbProperty<EmptyObject | null>;
jsonbEmpty: JsonbProperty<EmptyObject>;
jsonbArray: JsonbProperty<string[]>;
jsonbNested: JsonbProperty<{ nested: { deep: number } }>;
jsonbNullable: JsonbProperty<EmptyObject> | null;
jsonbUndefinable: JsonbProperty<EmptyObject> | undefined;
jsonbOptional?: JsonbProperty<EmptyObject>;
jsonbInnerNullable: JsonbProperty<EmptyObject | null>;
jsonbInnerUndefinable: JsonbProperty<EmptyObject | undefined>;
jsonbUnionWithPrimitive: JsonbProperty<EmptyObject> | string | null;
jsonbInnerNullableWithProperties: JsonbProperty<null | { value: string }>;
wrongUsageButPassing:
| JsonbProperty<null | { value: string }>
| string
| { foo: string };
};
type TestResult = ExtractJsonbProperties<TestedRecord>;
// eslint-disable-next-line unused-imports/no-unused-vars
type Assertions = [
Expect<
Equal<
TestResult,
| 'jsonbPlainObject'
| 'jsonbPlainArray'
| 'jsonbPlainObjectNullable'
| 'jsonbEmpty'
| 'jsonbArray'
| 'jsonbNested'
| 'jsonbNullable'
| 'jsonbUndefinable'
| 'jsonbOptional'
| 'jsonbInnerNullable'
| 'jsonbInnerUndefinable'
| 'jsonbInnerNullableWithProperties'
| 'jsonbUnionWithPrimitive'
| 'wrongUsageButPassing'
>
>,
// Empty object returns never
Expect<Equal<ExtractJsonbProperties<EmptyObject>, never>>,
// Object with no JsonbProperty fields returns never
Expect<Equal<ExtractJsonbProperties<{ a: string; b: number }>, never>>,
];

View file

@ -0,0 +1,173 @@
import { type Equal, type Expect } from 'twenty-shared/testing';
import { type SerializedRelation } from 'twenty-shared/types';
import { type FormatJsonbSerializedRelation } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/format-jsonb-serialized-relation.type';
import {
type JSONB_PROPERTY_BRAND,
type JsonbProperty,
} from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
type BrandedObjectWithRelation = JsonbProperty<{
name: string;
targetFieldMetadataId: SerializedRelation;
}>;
type BrandedObjectWithoutRelation = JsonbProperty<{
name: string;
count: number;
}>;
type UnbrandedObject = {
name: string;
targetFieldMetadataId: SerializedRelation;
};
// eslint-disable-next-line unused-imports/no-unused-vars
type BrandedObjectAssertions = [
// Branded object with SerializedRelation: Id suffix renamed to UniversalIdentifier
Expect<
Equal<
FormatJsonbSerializedRelation<BrandedObjectWithRelation>,
{
name: string;
targetFieldMetadataUniversalIdentifier: SerializedRelation;
}
>
>,
// Branded object without SerializedRelation: no renaming, just removes brand
Expect<
Equal<
FormatJsonbSerializedRelation<BrandedObjectWithoutRelation>,
{
name: string;
count: number;
}
>
>,
];
// eslint-disable-next-line unused-imports/no-unused-vars
type UnbrandedObjectAssertions = [
// Unbranded objects pass through unchanged
Expect<
Equal<FormatJsonbSerializedRelation<UnbrandedObject>, UnbrandedObject>
>,
];
// eslint-disable-next-line unused-imports/no-unused-vars
type PrimitiveAssertions = [
// Primitives pass through unchanged
Expect<Equal<FormatJsonbSerializedRelation<string>, string>>,
Expect<Equal<FormatJsonbSerializedRelation<number>, number>>,
Expect<Equal<FormatJsonbSerializedRelation<null>, null>>,
];
// eslint-disable-next-line unused-imports/no-unused-vars
type ArrayAssertions = [
// Array of branded objects: transforms each element
Expect<
Equal<
FormatJsonbSerializedRelation<BrandedObjectWithRelation[]>,
{
name: string;
targetFieldMetadataUniversalIdentifier: SerializedRelation;
}[]
>
>,
// Array of unbranded objects: passes through unchanged
Expect<
Equal<FormatJsonbSerializedRelation<UnbrandedObject[]>, UnbrandedObject[]>
>,
// Array of primitives: passes through unchanged
Expect<Equal<FormatJsonbSerializedRelation<string[]>, string[]>>,
// Nested array of branded objects: transforms innermost elements
Expect<
Equal<
FormatJsonbSerializedRelation<BrandedObjectWithRelation[][]>,
{
name: string;
targetFieldMetadataUniversalIdentifier: SerializedRelation;
}[][]
>
>,
// Array of unbranded and branded objects union: transforms branded element
Expect<
Equal<
FormatJsonbSerializedRelation<
(BrandedObjectWithRelation | UnbrandedObject)[]
>,
(
| {
name: string;
targetFieldMetadataUniversalIdentifier: SerializedRelation;
}
| UnbrandedObject
)[]
>
>,
];
// eslint-disable-next-line unused-imports/no-unused-vars
type UnionAssertions = [
// Union with null: transforms branded object, keeps null
Expect<
Equal<
FormatJsonbSerializedRelation<BrandedObjectWithRelation | null>,
{
name: string;
targetFieldMetadataUniversalIdentifier: SerializedRelation;
} | null
>
>,
// Array of union: transforms elements appropriately
Expect<
Equal<
FormatJsonbSerializedRelation<(BrandedObjectWithRelation | null)[]>,
({
name: string;
targetFieldMetadataUniversalIdentifier: SerializedRelation;
} | null)[]
>
>,
];
type MultipleRelationsObject = JsonbProperty<{
name: string;
sourceFieldId: SerializedRelation;
targetFieldId: SerializedRelation;
regularId: string;
}>;
// eslint-disable-next-line unused-imports/no-unused-vars
type MultipleRelationsAssertions = [
// Multiple SerializedRelation properties: all get renamed
Expect<
Equal<
FormatJsonbSerializedRelation<MultipleRelationsObject>,
{
name: string;
sourceFieldUniversalIdentifier: SerializedRelation;
targetFieldUniversalIdentifier: SerializedRelation;
regularId: string;
}
>
>,
];
// Verify brand is removed
type BrandRemovedCheck =
FormatJsonbSerializedRelation<BrandedObjectWithRelation>;
// eslint-disable-next-line unused-imports/no-unused-vars
type BrandRemovedAssertion = Expect<
Equal<
typeof JSONB_PROPERTY_BRAND extends keyof BrandRemovedCheck ? true : false,
false
>
>;

View file

@ -0,0 +1,97 @@
import { type Equal, type Expect } from 'twenty-shared/testing';
import {
type JSONB_PROPERTY_BRAND,
type JsonbProperty,
} from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
type EmptyObject = {};
type SimpleObject = { value: string };
type NestedObject = { nested: { deep: number } };
// eslint-disable-next-line unused-imports/no-unused-vars
type PrimitiveAssertions = [
// Primitives pass through unchanged (not objects)
Expect<Equal<JsonbProperty<string>, string>>,
Expect<Equal<JsonbProperty<number>, number>>,
Expect<Equal<JsonbProperty<boolean>, boolean>>,
Expect<Equal<JsonbProperty<null>, null>>,
Expect<Equal<JsonbProperty<undefined>, undefined>>,
];
// eslint-disable-next-line unused-imports/no-unused-vars
type ObjectAssertions = [
// Objects get branded
Expect<
Equal<
JsonbProperty<SimpleObject>,
SimpleObject & { [JSONB_PROPERTY_BRAND]?: never }
>
>,
Expect<
Equal<
JsonbProperty<NestedObject>,
NestedObject & { [JSONB_PROPERTY_BRAND]?: never }
>
>,
Expect<
Equal<
JsonbProperty<EmptyObject>,
EmptyObject & { [JSONB_PROPERTY_BRAND]?: never }
>
>,
];
// eslint-disable-next-line unused-imports/no-unused-vars
type ArrayAssertions = [
// Arrays are objects, so they get branded on the array itself
Expect<
Equal<
JsonbProperty<string[]>,
string[] & { [JSONB_PROPERTY_BRAND]?: never }
>
>,
Expect<
Equal<
JsonbProperty<number[]>,
number[] & { [JSONB_PROPERTY_BRAND]?: never }
>
>,
Expect<
Equal<
JsonbProperty<SimpleObject[]>,
SimpleObject[] & { [JSONB_PROPERTY_BRAND]?: never }
>
>,
];
// eslint-disable-next-line unused-imports/no-unused-vars
type UnionAssertions = [
// Union of object and null: object gets branded, null passes through
Expect<
Equal<
JsonbProperty<SimpleObject | null>,
(SimpleObject & { [JSONB_PROPERTY_BRAND]?: never }) | null
>
>,
// Union of objects: both get branded (distributive conditional)
Expect<
Equal<
JsonbProperty<SimpleObject | NestedObject>,
| (SimpleObject & { [JSONB_PROPERTY_BRAND]?: never })
| (NestedObject & { [JSONB_PROPERTY_BRAND]?: never })
>
>,
// Array in union: array gets branded
Expect<
Equal<
JsonbProperty<SimpleObject[] | null>,
(SimpleObject[] & { [JSONB_PROPERTY_BRAND]?: never }) | null
>
>,
];

View file

@ -1,7 +1,18 @@
import { type Expect, type HasAllProperties } from 'twenty-shared/testing';
import {
type Equal,
type Expect,
type HasAllProperties,
} from 'twenty-shared/testing';
import {
type FieldMetadataDefaultOption,
type FieldMetadataType,
type FieldNumberVariant,
type LinkMetadata,
type NullablePartial,
type NumberDataType,
type RelationOnDeleteAction,
type RelationType,
type SerializedRelation,
} from 'twenty-shared/types';
import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type';
@ -79,3 +90,69 @@ type UniversalFlatTransformationAssertions = [
>
>,
];
type NarrowedTestCase =
UniversalFlatFieldMetadata<FieldMetadataType.RELATION>['settings'];
type NarrowedExpectedResult = {
relationType: RelationType;
onDelete?: RelationOnDeleteAction | undefined;
joinColumnName?: string | null | undefined;
junctionTargetFieldUniversalIdentifier?: SerializedRelation | undefined;
};
type SettingsTestCase = UniversalFlatFieldMetadata<
FieldMetadataType.RELATION | FieldMetadataType.NUMBER | FieldMetadataType.TEXT
>['settings'];
type SettingsExpectedResult =
| {
relationType: RelationType;
onDelete?: RelationOnDeleteAction | undefined;
joinColumnName?: string | null | undefined;
junctionTargetFieldUniversalIdentifier?: SerializedRelation | undefined;
}
| {
dataType?: NumberDataType | undefined;
decimals?: number | undefined;
type?: FieldNumberVariant | undefined;
}
| {
displayedMaxRows?: number | undefined;
}
| null;
type DefaultValueTestCase = UniversalFlatFieldMetadata<
| FieldMetadataType.RELATION
| FieldMetadataType.NUMBER
| FieldMetadataType.TEXT
| FieldMetadataType.LINKS
| FieldMetadataType.CURRENCY
>['defaultValue'];
type DefaultValueExpectedResult =
| string
| number
| null
| {
amountMicros: string | null;
currencyCode: string | null;
}
| {
primaryLinkLabel: string | null;
primaryLinkUrl: string | null;
secondaryLinks: LinkMetadata[] | null;
};
type OptionsTestCase =
UniversalFlatFieldMetadata<FieldMetadataType.RATING>['options'];
type OptionsExpectedResult = FieldMetadataDefaultOption[];
// eslint-disable-next-line unused-imports/no-unused-vars
type Assertions = [
Expect<Equal<SettingsTestCase, SettingsExpectedResult>>,
Expect<Equal<NarrowedTestCase, NarrowedExpectedResult>>,
Expect<Equal<DefaultValueTestCase, DefaultValueExpectedResult>>,
Expect<Equal<OptionsTestCase, OptionsExpectedResult>>,
];

View file

@ -0,0 +1,17 @@
import { type JSONB_PROPERTY_BRAND } from './jsonb-property.type';
export type HasJsonbPropertyBrand<T> =
typeof JSONB_PROPERTY_BRAND extends keyof T ? true : false;
// Distributive check: returns `true` if any member of a union has the brand
type HasJsonbBrandInUnion<T> = T extends unknown
? HasJsonbPropertyBrand<T>
: never;
export type ExtractJsonbProperties<T> = NonNullable<
{
[P in keyof T]-?: true extends HasJsonbBrandInUnion<NonNullable<T[P]>>
? P
: never;
}[keyof T]
>;

View file

@ -0,0 +1,21 @@
import { type ExtractSerializedRelationProperties } from 'twenty-shared/types';
import { type HasJsonbPropertyBrand } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/extract-jsonb-properties.type';
import { type JSONB_PROPERTY_BRAND } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
import { type RemoveSuffix } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/remove-suffix.type';
export type FormatJsonbSerializedRelation<T> = T extends unknown
? T extends (infer U)[]
? FormatJsonbSerializedRelation<U>[]
: HasJsonbPropertyBrand<T> extends true
? Omit<
{
[P in keyof T as P extends ExtractSerializedRelationProperties<T> &
string
? `${RemoveSuffix<P, 'Id'>}UniversalIdentifier`
: P]: T[P];
},
typeof JSONB_PROPERTY_BRAND
>
: T
: never;

View file

@ -0,0 +1,7 @@
export const JSONB_PROPERTY_BRAND = '__JsonbPropertyBrand__' as const;
export type JsonbProperty<T> = T extends unknown
? T extends object
? T & { [JSONB_PROPERTY_BRAND]?: never }
: T
: never;

View file

@ -6,6 +6,8 @@ import { type ExtractEntityRelatedEntityProperties } from 'src/engine/metadata-m
import { type FromMetadataEntityToMetadataName } from 'src/engine/metadata-modules/flat-entity/types/from-metadata-entity-to-metadata-name.type';
import { type MetadataManyToOneJoinColumn } from 'src/engine/metadata-modules/flat-entity/types/metadata-many-to-one-join-column.type';
import { type SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
import { type ExtractJsonbProperties } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/extract-jsonb-properties.type';
import { type FormatJsonbSerializedRelation } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/format-jsonb-serialized-relation.type';
import { type RemoveSuffix } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/remove-suffix.type';
// TODO Handle universal settings
@ -22,6 +24,7 @@ export type UniversalFlatEntityFrom<
| ExtractEntityRelatedEntityProperties<TEntity>
| Extract<MetadataManyToOneJoinColumn<TMetadataName>, keyof TEntity>
| keyof CastRecordTypeOrmDatePropertiesToString<TEntity>
| ExtractJsonbProperties<TEntity>
> &
CastRecordTypeOrmDatePropertiesToString<TEntity> & {
[P in ExtractEntityOneToManyEntityRelationProperties<
@ -34,4 +37,8 @@ export type UniversalFlatEntityFrom<
string as `${RemoveSuffix<P, 'Id'>}UniversalIdentifier`]: TEntity[P];
} & {
applicationUniversalIdentifier: string;
} & {
[P in ExtractJsonbProperties<TEntity>]: FormatJsonbSerializedRelation<
TEntity[P]
>;
};

View file

@ -1,6 +1,5 @@
import { type ColumnType } from 'typeorm';
import { type FieldMetadataDefaultSerializableValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { type FieldMetadataDefaultValueForAnyType } from 'twenty-shared/types';
import {
FieldMetadataException,
@ -11,7 +10,7 @@ import { serializeFunctionDefaultValue } from 'src/engine/metadata-modules/field
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
type SerializeDefaultValueArgs = {
defaultValue?: FieldMetadataDefaultSerializableValue;
defaultValue?: FieldMetadataDefaultValueForAnyType;
columnType?: ColumnType;
schemaName: string;
tableName: string;

View file

@ -3,9 +3,9 @@ import {
type FieldMetadataTypesToTestForFilterInputValidation,
} from 'test/integration/graphql/suites/inputs-validation/types/field-metadata-type-to-test';
import {
type FieldMetadataSettingsMapping,
FieldMetadataType,
RelationType,
type FieldMetadataFilesSettings,
type FieldMetadataMultiItemSettings,
type RelationCreationPayload,
} from 'twenty-shared/types';
@ -20,7 +20,9 @@ type FieldMetadataCreationInput = {
options?: FieldMetadataComplexOption[];
relationCreationPayload?: RelationCreationPayload;
morphRelationsCreationPayload?: RelationCreationPayload[];
settings?: FieldMetadataMultiItemSettings | FieldMetadataFilesSettings;
settings?:
| FieldMetadataMultiItemSettings
| FieldMetadataSettingsMapping['FILES'];
};
export const getFieldMetadataCreationInputs = (

View file

@ -1,11 +1,5 @@
import { ViewFilterOperand } from 'twenty-shared/types';
import { type UpdateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/update-view-field.input';
import { type ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import { type ViewFilterGroupEntity } from 'src/engine/metadata-modules/view-filter-group/entities/view-filter-group.entity';
import { ViewFilterGroupLogicalOperator } from 'src/engine/metadata-modules/view-filter-group/enums/view-filter-group-logical-operator';
import { type ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
import { type ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity';
import { type ViewSortEntity } from 'src/engine/metadata-modules/view-sort/entities/view-sort.entity';
import { ViewSortDirection } from 'src/engine/metadata-modules/view-sort/enums/view-sort-direction';
import { type ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
@ -25,33 +19,6 @@ export const createViewData = (overrides: Partial<ViewEntity> = {}) => ({
...overrides,
});
export const updateViewData = (overrides: Partial<ViewEntity> = {}) => ({
name: 'Updated View',
type: ViewType.KANBAN,
isCompact: true,
...overrides,
});
export const createViewFieldData = (
viewId: string,
overrides: Partial<ViewFieldEntity> = {},
) => ({
viewId,
position: 0,
isVisible: true,
size: 150,
...overrides,
});
export const updateViewFieldData = (
overrides: Partial<UpdateViewFieldInput['update']> = {},
) => ({
position: 5,
isVisible: false,
size: 300,
...overrides,
});
export const createViewSortData = (
viewId: string,
overrides: Partial<ViewSortEntity> = {},
@ -68,44 +35,6 @@ export const updateViewSortData = (
...overrides,
});
export const createViewFilterData = (
viewId: string,
overrides: Partial<ViewFilterEntity> = {},
) => ({
viewId,
operand: ViewFilterOperand.IS,
value: 'test-value',
...overrides,
});
export const updateViewFilterData = (
overrides: Partial<ViewFilterEntity> = {},
) => ({
operand: ViewFilterOperand.IS_NOT,
value: 'updated-value',
...overrides,
});
export const createViewGroupData = (
viewId: string,
overrides: Partial<ViewGroupEntity> = {},
) => ({
viewId,
fieldValue: 'test-group-value',
isVisible: true,
position: 0,
...overrides,
});
export const updateViewGroupData = (
overrides: Partial<ViewGroupEntity> = {},
) => ({
fieldValue: 'updated-group-value',
isVisible: false,
position: 1,
...overrides,
});
export const createViewFilterGroupData = (
viewId: string,
overrides: Partial<ViewFilterGroupEntity> = {},

View file

@ -0,0 +1,16 @@
import { type SERIALIZED_RELATION_BRAND } from './SerializedRelation.type';
type HasSerializedRelationPropertyBrand<T> =
typeof SERIALIZED_RELATION_BRAND extends keyof T ? true : false;
export type ExtractSerializedRelationProperties<T> = T extends unknown
? T extends object
? {
[P in keyof T]-?: [NonNullable<T[P]>] extends [never]
? never
: HasSerializedRelationPropertyBrand<NonNullable<T[P]>> extends true
? P
: never;
}[keyof T]
: never
: never;

View file

@ -1,43 +1,7 @@
import { type LinkMetadata } from '@/types/composite-types/links.composite-type';
import { type FieldMetadataType } from '@/types/FieldMetadataType';
import { type IsExactly } from '@/types/IsExactly';
import {
IsArray,
IsBoolean,
IsDate,
IsNotEmpty,
IsNumber,
IsNumberString,
IsObject,
IsOptional,
IsString,
IsUUID,
Matches,
ValidateIf,
type ValidationArguments,
type ValidationOptions,
registerDecorator,
} from 'class-validator';
const IsQuotedString = (validationOptions?: ValidationOptions) => {
return (object: object, propertyName: string) => {
registerDecorator({
name: 'isQuotedString',
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: {
validate: (value: any) => {
return typeof value === 'string' && /^'{1}.*'{1}$/.test(value);
},
defaultMessage: (args: ValidationArguments) => {
return `${args.property} must be a quoted string`;
},
},
});
};
};
export const fieldMetadataDefaultValueFunctionName = {
UUID: 'uuid',
NOW: 'now',
@ -46,266 +10,105 @@ export const fieldMetadataDefaultValueFunctionName = {
export type FieldMetadataDefaultValueFunctionNames =
(typeof fieldMetadataDefaultValueFunctionName)[keyof typeof fieldMetadataDefaultValueFunctionName];
export class FieldMetadataDefaultValueString {
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
value!: string | null;
}
export type FieldMetadataDefaultValueUuidFunction =
typeof fieldMetadataDefaultValueFunctionName.UUID;
export type FieldMetadataDefaultValueNowFunction =
typeof fieldMetadataDefaultValueFunctionName.NOW;
export class FieldMetadataDefaultValueRawJson {
@ValidateIf((_object, value) => value !== null)
@IsObject() // TODO: Should this also allow arrays?
value!: object | null;
}
export class FieldMetadataDefaultValueRichTextV2 {
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
blocknote!: string | null;
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
markdown!: string | null;
}
export class FieldMetadataDefaultValueRichText {
@ValidateIf((_object, value) => value !== null)
@IsString()
value!: string | null;
}
export class FieldMetadataDefaultValueNumber {
@ValidateIf((_object, value) => value !== null)
@IsNumber()
value!: number | null;
}
export class FieldMetadataDefaultValueBoolean {
@ValidateIf((_object, value) => value !== null)
@IsBoolean()
value!: boolean | null;
}
export class FieldMetadataDefaultValueStringArray {
@ValidateIf((_object, value) => value !== null)
@IsArray()
@IsQuotedString({ each: true })
value!: string[] | null;
}
export class FieldMetadataDefaultValueDateTime {
@ValidateIf((_object, value) => value !== null)
@IsDate()
value!: Date | null;
}
export class FieldMetadataDefaultValueDate {
@ValidateIf((_object, value) => value !== null)
@IsDate()
value!: Date | null;
}
export class FieldMetadataDefaultValueCurrency {
@ValidateIf((_object, value) => value !== null)
@IsNumberString()
amountMicros!: string | null;
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
currencyCode!: string | null;
}
export class FieldMetadataDefaultValueFullName {
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
firstName!: string | null;
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
lastName!: string | null;
}
export class FieldMetadataDefaultValueUuidFunction {
@Matches(fieldMetadataDefaultValueFunctionName.UUID)
@IsNotEmpty()
value!: typeof fieldMetadataDefaultValueFunctionName.UUID;
}
export class FieldMetadataDefaultValueNowFunction {
@Matches(fieldMetadataDefaultValueFunctionName.NOW)
@IsNotEmpty()
value!: typeof fieldMetadataDefaultValueFunctionName.NOW;
}
export class FieldMetadataDefaultValueAddress {
@ValidateIf((_object, value) => value !== null)
@IsString()
addressStreet1!: string | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
addressStreet2!: string | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
addressCity!: string | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
addressPostcode!: string | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
addressState!: string | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
addressCountry!: string | null;
@ValidateIf((_object, value) => value !== null)
@IsNumber()
addressLat!: number | null;
@ValidateIf((_object, value) => value !== null)
@IsNumber()
addressLng!: number | null;
}
class LinkMetadata {
@IsString()
label!: string;
@IsString()
url!: string;
}
export class FieldMetadataDefaultValueLinks {
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
primaryLinkLabel!: string | null;
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
primaryLinkUrl!: string | null;
@ValidateIf((_object, value) => value !== null)
@IsArray()
secondaryLinks!: LinkMetadata[] | null;
}
export class FieldMetadataDefaultActor {
@ValidateIf((_object, value) => value !== null)
@IsString()
source!: string;
@ValidateIf((_object, value) => value !== null)
@IsOptional()
@IsUUID()
workspaceMemberId?: string | null;
@ValidateIf((_object, value) => value !== null)
@IsString()
name!: string;
}
export class FieldMetadataDefaultValueEmails {
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
primaryEmail!: string | null;
@ValidateIf((_object, value) => value !== null)
@IsObject()
additionalEmails!: object | null;
}
export class FieldMetadataDefaultValuePhones {
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
primaryPhoneNumber!: string | null;
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
primaryPhoneCountryCode!: string | null;
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
primaryPhoneCallingCode!: string | null;
@ValidateIf((_object, value) => value !== null)
@IsObject()
additionalPhones!: object | null;
}
export class FieldMetadataDefaultArray {
@ValidateIf((_object, value) => value !== null)
@IsArray()
value!: string[] | null;
}
type ExtractValueType<T> = T extends { value: infer V } ? V : T;
type UnionOfValues<T> = T[keyof T];
type FieldMetadataDefaultValueMapping = {
[FieldMetadataType.UUID]:
| FieldMetadataDefaultValueString
| FieldMetadataDefaultValueUuidFunction;
[FieldMetadataType.TEXT]: FieldMetadataDefaultValueString;
[FieldMetadataType.PHONES]: FieldMetadataDefaultValuePhones;
[FieldMetadataType.EMAILS]: FieldMetadataDefaultValueEmails;
[FieldMetadataType.DATE_TIME]:
| FieldMetadataDefaultValueDateTime
| FieldMetadataDefaultValueNowFunction;
[FieldMetadataType.DATE]:
| FieldMetadataDefaultValueDateTime
| FieldMetadataDefaultValueNowFunction;
[FieldMetadataType.BOOLEAN]: FieldMetadataDefaultValueBoolean;
[FieldMetadataType.NUMBER]: FieldMetadataDefaultValueNumber;
[FieldMetadataType.POSITION]: FieldMetadataDefaultValueNumber;
[FieldMetadataType.NUMERIC]: FieldMetadataDefaultValueString;
[FieldMetadataType.LINKS]: FieldMetadataDefaultValueLinks;
[FieldMetadataType.CURRENCY]: FieldMetadataDefaultValueCurrency;
[FieldMetadataType.FULL_NAME]: FieldMetadataDefaultValueFullName;
[FieldMetadataType.ADDRESS]: FieldMetadataDefaultValueAddress;
[FieldMetadataType.RATING]: FieldMetadataDefaultValueString;
[FieldMetadataType.SELECT]: FieldMetadataDefaultValueString;
[FieldMetadataType.MULTI_SELECT]: FieldMetadataDefaultValueStringArray;
[FieldMetadataType.RAW_JSON]: FieldMetadataDefaultValueRawJson;
[FieldMetadataType.RICH_TEXT]: FieldMetadataDefaultValueRichText;
[FieldMetadataType.ACTOR]: FieldMetadataDefaultActor;
[FieldMetadataType.ARRAY]: FieldMetadataDefaultArray;
export type FieldMetadataDefaultValueRichTextV2 = {
blocknote: string | null;
markdown: string | null;
};
export type FieldMetadataClassValidation =
UnionOfValues<FieldMetadataDefaultValueMapping>;
export type FieldMetadataDefaultValueCurrency = {
amountMicros: string | null;
currencyCode: string | null;
};
export type FieldMetadataFunctionDefaultValue = ExtractValueType<
FieldMetadataDefaultValueUuidFunction | FieldMetadataDefaultValueNowFunction
>;
export type FieldMetadataDefaultValueFullName = {
firstName: string | null;
lastName: string | null;
};
export type FieldMetadataDefaultValueForType<
T extends keyof FieldMetadataDefaultValueMapping,
> = ExtractValueType<FieldMetadataDefaultValueMapping[T]> | null;
export type FieldMetadataDefaultValueAddress = {
addressStreet1: string | null;
addressStreet2: string | null;
addressCity: string | null;
addressPostcode: string | null;
addressState: string | null;
addressCountry: string | null;
addressLat: number | null;
addressLng: number | null;
};
export type FieldMetadataDefaultValueForAnyType = ExtractValueType<
UnionOfValues<FieldMetadataDefaultValueMapping>
> | null;
export type FieldMetadataDefaultValueLinks = {
primaryLinkLabel: string | null;
primaryLinkUrl: string | null;
secondaryLinks: LinkMetadata[] | null;
};
export type FieldMetadataDefaultActor = {
source: string;
workspaceMemberId?: string | null;
name: string;
};
export type FieldMetadataDefaultValueEmails = {
primaryEmail: string | null;
additionalEmails: object | null;
};
export type FieldMetadataDefaultValuePhones = {
primaryPhoneNumber: string | null;
primaryPhoneCountryCode: string | null;
primaryPhoneCallingCode: string | null;
additionalPhones: object | null;
};
export type FieldMetadataDefaultValueMapping = {
[FieldMetadataType.UUID]:
| string
| FieldMetadataDefaultValueUuidFunction
| null;
[FieldMetadataType.TEXT]: string | null;
[FieldMetadataType.PHONES]: FieldMetadataDefaultValuePhones | null;
[FieldMetadataType.EMAILS]: FieldMetadataDefaultValueEmails | null;
[FieldMetadataType.DATE_TIME]:
| Date
| FieldMetadataDefaultValueNowFunction
| null;
[FieldMetadataType.DATE]: Date | FieldMetadataDefaultValueNowFunction | null;
[FieldMetadataType.BOOLEAN]: boolean | null;
[FieldMetadataType.NUMBER]: number | null;
[FieldMetadataType.POSITION]: number | null;
[FieldMetadataType.NUMERIC]: string | null;
[FieldMetadataType.LINKS]: FieldMetadataDefaultValueLinks | null;
[FieldMetadataType.CURRENCY]: FieldMetadataDefaultValueCurrency | null;
[FieldMetadataType.FULL_NAME]: FieldMetadataDefaultValueFullName | null;
[FieldMetadataType.ADDRESS]: FieldMetadataDefaultValueAddress | null;
[FieldMetadataType.RATING]: string | null;
[FieldMetadataType.SELECT]: string | null;
[FieldMetadataType.MULTI_SELECT]: string[] | null;
[FieldMetadataType.RAW_JSON]: object | null;
[FieldMetadataType.RICH_TEXT]: string | null;
[FieldMetadataType.RICH_TEXT_V2]: FieldMetadataDefaultValueRichTextV2 | null;
[FieldMetadataType.ACTOR]: FieldMetadataDefaultActor | null;
[FieldMetadataType.ARRAY]: string[] | null;
};
export type FieldMetadataFunctionDefaultValue =
| FieldMetadataDefaultValueUuidFunction
| FieldMetadataDefaultValueNowFunction;
export type FieldMetadataDefaultValueForAnyType =
| null
| FieldMetadataDefaultValueMapping[keyof FieldMetadataDefaultValueMapping];
export type FieldMetadataDefaultValue<
T extends FieldMetadataType = FieldMetadataType,
> =
IsExactly<T, FieldMetadataType> extends true
? FieldMetadataDefaultValueForAnyType | null // Could be improved to be | unknown
? FieldMetadataDefaultValueForAnyType
: T extends keyof FieldMetadataDefaultValueMapping
? FieldMetadataDefaultValueForType<T>
? FieldMetadataDefaultValueMapping[T]
: never | null;
type FieldMetadataDefaultValueExtractedTypes = {
[K in keyof FieldMetadataDefaultValueMapping]: ExtractValueType<
FieldMetadataDefaultValueMapping[K]
>;
};
export type FieldMetadataDefaultSerializableValue =
| FieldMetadataDefaultValueExtractedTypes[keyof FieldMetadataDefaultValueExtractedTypes]
| null;

View file

@ -30,11 +30,15 @@ type FieldMetadataOptionsMapping = {
[FieldMetadataType.MULTI_SELECT]: FieldMetadataComplexOption[];
};
export type FieldMetadataOptionForAnyType =
| null
| FieldMetadataOptionsMapping[keyof FieldMetadataOptionsMapping];
export type FieldMetadataOptions<
T extends FieldMetadataType = FieldMetadataType,
> =
IsExactly<T, FieldMetadataType> extends true
? null | (FieldMetadataDefaultOption[] | FieldMetadataComplexOption[]) // Could be improved to be | unknown
? FieldMetadataOptionForAnyType
: T extends keyof FieldMetadataOptionsMapping
? FieldMetadataOptionsMapping[T]
: never | null;

View file

@ -4,6 +4,7 @@ import { type FieldMetadataType } from '@/types/FieldMetadataType';
import { type IsExactly } from '@/types/IsExactly';
import { type RelationOnDeleteAction } from '@/types/RelationOnDeleteAction.type';
import { type RelationType } from '@/types/RelationType';
import { type SerializedRelation } from '@/types/SerializedRelation.type';
export enum NumberDataType {
FLOAT = 'float',
@ -19,46 +20,47 @@ export enum DateDisplayFormat {
export type FieldNumberVariant = 'number' | 'percentage';
export type FieldMetadataNumberSettings = {
type FieldMetadataNumberSettings = {
dataType?: NumberDataType;
decimals?: number;
type?: FieldNumberVariant;
};
export type FieldMetadataTextSettings = {
type FieldMetadataTextSettings = {
displayedMaxRows?: number;
};
export type FieldMetadataDateSettings = {
type FieldMetadataDateSettings = {
displayFormat?: DateDisplayFormat;
};
export type FieldMetadataDateTimeSettings = {
type FieldMetadataDateTimeSettings = {
displayFormat?: DateDisplayFormat;
};
export type FieldMetadataRelationSettings = {
type FieldMetadataRelationSettings = {
relationType: RelationType;
onDelete?: RelationOnDeleteAction;
joinColumnName?: string | null;
// Points to the target field on the junction object
// For MORPH_RELATION fields, morphRelations already contains all targets
junctionTargetFieldId?: string;
junctionTargetFieldId?: SerializedRelation;
};
export type FieldMetadataAddressSettings = {
type FieldMetadataAddressSettings = {
subFields?: AllowedAddressSubField[];
};
export type FieldMetadataFilesSettings = {
type FieldMetadataFilesSettings = {
maxNumberOfValues: number;
};
export type FieldMetadataTsVectorSettings = {
type FieldMetadataTsVectorSettings = {
asExpression?: string;
generatedType?: 'STORED' | 'VIRTUAL';
};
type FieldMetadataSettingsMapping = {
export type FieldMetadataSettingsMapping = {
[FieldMetadataType.NUMBER]: FieldMetadataNumberSettings | null;
[FieldMetadataType.DATE]: FieldMetadataDateSettings | null;
[FieldMetadataType.DATE_TIME]: FieldMetadataDateTimeSettings | null;
@ -81,7 +83,7 @@ export type FieldMetadataSettings<
T extends FieldMetadataType = FieldMetadataType,
> =
IsExactly<T, FieldMetadataType> extends true
? null | AllFieldMetadataSettings // Could be improved to be | unknown
? null | AllFieldMetadataSettings
: T extends keyof FieldMetadataSettingsMapping
? FieldMetadataSettingsMapping[T]
: never | null;

View file

@ -12,5 +12,4 @@ export type RowLevelPermissionPredicateValue =
| number
| RelationPredicateValue
| Record<string, unknown>
| null
| undefined;
| null;

View file

@ -0,0 +1,5 @@
export const SERIALIZED_RELATION_BRAND = '__SerializedRelationBrand__' as const;
export type SerializedRelation = string & {
[SERIALIZED_RELATION_BRAND]?: never;
};

View file

@ -0,0 +1,69 @@
import { Equal, Expect } from '@/testing';
import { type ExtractSerializedRelationProperties } from '@/types/ExtractSerializedRelationProperties.type';
import { type SerializedRelation } from '@/types/SerializedRelation.type';
type TestedRecord = {
// Non-SerializedRelation fields
plainString: string;
plainNumber: number;
plainBoolean: boolean;
plainObject: { id: string };
plainArray: string[];
plainUnknown: unknown;
plainStringNullable: string | null;
plainStringOptional?: string;
// SerializedRelation fields - should be extracted
relation: SerializedRelation;
relationNullable: SerializedRelation | null;
relationUndefinable: SerializedRelation | undefined;
relationOptional?: SerializedRelation;
relationOptionalNullable?: SerializedRelation | null;
};
type TestResult = ExtractSerializedRelationProperties<TestedRecord>;
// eslint-disable-next-line unused-imports/no-unused-vars
type Assertions = [
Expect<
Equal<
TestResult,
| 'relation'
| 'relationNullable'
| 'relationUndefinable'
| 'relationOptional'
| 'relationOptionalNullable'
>
>,
// Object with no SerializedRelation fields returns never
Expect<
Equal<ExtractSerializedRelationProperties<{ a: string; b: number }>, never>
>,
// Primitives return never (tests T extends object branch)
Expect<Equal<ExtractSerializedRelationProperties<string>, never>>,
Expect<Equal<ExtractSerializedRelationProperties<number>, never>>,
Expect<Equal<ExtractSerializedRelationProperties<null>, never>>,
// Empty object returns never
Expect<Equal<ExtractSerializedRelationProperties<object>, never>>,
// Union types distribute correctly
Expect<
Equal<
ExtractSerializedRelationProperties<
{ a: SerializedRelation } | { b: SerializedRelation }
>,
'a' | 'b'
>
>,
// Mixed union with object and primitive
Expect<
Equal<
ExtractSerializedRelationProperties<{ rel: SerializedRelation } | string>,
'rel'
>
>,
];

View file

@ -55,54 +55,39 @@ export type { EnumFieldMetadataType } from './EnumFieldMetadataType';
export type { ExcludeFunctions } from './ExcludeFunctions';
export type { ExtractPropertiesThatEndsWithId } from './ExtractPropertiesThatEndsWithId';
export type { ExtractPropertiesThatEndsWithIds } from './ExtractPropertiesThatEndsWithIds';
export type { ExtractSerializedRelationProperties } from './ExtractSerializedRelationProperties.type';
export type {
FieldMetadataDefaultValueFunctionNames,
FieldMetadataClassValidation,
FieldMetadataFunctionDefaultValue,
FieldMetadataDefaultValueForType,
FieldMetadataDefaultValueForAnyType,
FieldMetadataDefaultValue,
FieldMetadataDefaultSerializableValue,
} from './FieldMetadataDefaultValue';
export {
fieldMetadataDefaultValueFunctionName,
FieldMetadataDefaultValueString,
FieldMetadataDefaultValueRawJson,
FieldMetadataDefaultValueRichTextV2,
FieldMetadataDefaultValueRichText,
FieldMetadataDefaultValueNumber,
FieldMetadataDefaultValueBoolean,
FieldMetadataDefaultValueStringArray,
FieldMetadataDefaultValueDateTime,
FieldMetadataDefaultValueDate,
FieldMetadataDefaultValueCurrency,
FieldMetadataDefaultValueFullName,
FieldMetadataDefaultValueUuidFunction,
FieldMetadataDefaultValueNowFunction,
FieldMetadataDefaultValueRichTextV2,
FieldMetadataDefaultValueCurrency,
FieldMetadataDefaultValueFullName,
FieldMetadataDefaultValueAddress,
FieldMetadataDefaultValueLinks,
FieldMetadataDefaultActor,
FieldMetadataDefaultValueEmails,
FieldMetadataDefaultValuePhones,
FieldMetadataDefaultArray,
FieldMetadataDefaultValueMapping,
FieldMetadataFunctionDefaultValue,
FieldMetadataDefaultValueForAnyType,
FieldMetadataDefaultValue,
} from './FieldMetadataDefaultValue';
export { fieldMetadataDefaultValueFunctionName } from './FieldMetadataDefaultValue';
export type { FieldMetadataMultiItemSettings } from './FieldMetadataMultiItemSettings';
export { FieldMetadataSettingsOnClickAction } from './FieldMetadataMultiItemSettings';
export type { TagColor, FieldMetadataOptions } from './FieldMetadataOptions';
export type {
TagColor,
FieldMetadataOptionForAnyType,
FieldMetadataOptions,
} from './FieldMetadataOptions';
export {
FieldMetadataDefaultOption,
FieldMetadataComplexOption,
} from './FieldMetadataOptions';
export type {
FieldNumberVariant,
FieldMetadataNumberSettings,
FieldMetadataTextSettings,
FieldMetadataDateSettings,
FieldMetadataDateTimeSettings,
FieldMetadataRelationSettings,
FieldMetadataAddressSettings,
FieldMetadataFilesSettings,
FieldMetadataTsVectorSettings,
FieldMetadataSettingsMapping,
AllFieldMetadataSettings,
FieldMetadataSettings,
} from './FieldMetadataSettings';
@ -200,6 +185,8 @@ export type {
RelationPredicateValue,
RowLevelPermissionPredicateValue,
} from './RowLevelPermissionPredicateValue';
export type { SerializedRelation } from './SerializedRelation.type';
export { SERIALIZED_RELATION_BRAND } from './SerializedRelation.type';
export type { ServerlessFunctionEvent } from './ServerlessFunctionEvent';
export { SettingsPath } from './SettingsPath';
export type { Sources } from './SourcesType';