[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:
-95
@@ -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);
|
||||
});
|
||||
});
|
||||
+5
-3
@@ -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(
|
||||
|
||||
+4
-7
@@ -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' &&
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
-118
@@ -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),
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user