common api - null equivalence (#15926)

closes https://github.com/twentyhq/core-team-issues/issues/1629

To do before requesting review : 
- filter update

Migration to come in an other PR


Strat : 
1/  Null transformation

- [x] Transform NULL equivalent value to NULL in field validation in
common api - pre-query - with feature flag
- [ ] Same logic in ORM (Not done, complex to handle feature flag here)
- [x] Transform NULL value to equivalent in data formatting in ORM -
post-query

2/ Migration (in other PR) for fieldMetadata not nullable with default
defaultValue (empty string, ...)

- [ ] Remove NOT NULL db constraint
- [ ] Update record value to NULL
- [ ] Update field metadata : isNullable:true
- [ ] Update uniqueIndex whereClause (also for standard uniqueIndex) 
- [ ] Activate feature flag

3/ Update metadata creation

- [x] No more default default value
- [x] Update standard field nullability
- [x] Remove index default whereClause for standard field

4/ Update filter

- [x] When filtering on NULL or empty string, be sure all records are
returned (the one with NULL + the one with "")

5/ Test

- [ ] Strat. to do
This commit is contained in:
Etienne
2025-11-24 18:57:47 +01:00
committed by GitHub
parent 3ed6d2a16b
commit 208c0857ee
59 changed files with 884 additions and 300 deletions
@@ -1275,6 +1275,7 @@ export enum FeatureFlagKey {
IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_MESSAGE_FOLDER_CONTROL_ENABLED = 'IS_MESSAGE_FOLDER_CONTROL_ENABLED',
IS_NULL_EQUIVALENCE_ENABLED = 'IS_NULL_EQUIVALENCE_ENABLED',
IS_PAGE_LAYOUT_ENABLED = 'IS_PAGE_LAYOUT_ENABLED',
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
@@ -1258,6 +1258,7 @@ export enum FeatureFlagKey {
IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_MESSAGE_FOLDER_CONTROL_ENABLED = 'IS_MESSAGE_FOLDER_CONTROL_ENABLED',
IS_NULL_EQUIVALENCE_ENABLED = 'IS_NULL_EQUIVALENCE_ENABLED',
IS_PAGE_LAYOUT_ENABLED = 'IS_PAGE_LAYOUT_ENABLED',
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
@@ -7,8 +7,7 @@ import { type RecordGqlFields } from '@/object-record/graphql/record-gql-fields/
import { generateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromObject';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { type ObjectPermissions } from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
import { isEmptyObject } from '~/utils/isEmptyObject';
import { capitalize, isEmptyObject } from 'twenty-shared/utils';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export type GetRecordFromCacheArgs = {
@@ -40,9 +40,8 @@ import {
type TSVectorFilter,
type UUIDFilter,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { isDefined, isEmptyObject } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isEmptyObject } from '~/utils/isEmptyObject';
const isLeafFilter = (
filter: RecordGqlOperationFilter,
@@ -8,12 +8,15 @@ import {
} from '@/spreadsheet-import/types';
import { isNonEmptyString } from '@sniptt/guards';
import { parsePhoneNumberWithError, type CountryCode } from 'libphonenumber-js';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import {
assertUnreachable,
isDefined,
isEmptyObject,
} from 'twenty-shared/utils';
import { z } from 'zod';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
import { castToString } from '~/utils/castToString';
import { convertCurrencyAmountToCurrencyMicros } from '~/utils/convertCurrencyToCurrencyMicros';
import { isEmptyObject } from '~/utils/isEmptyObject';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
type BuildRecordFromImportedStructuredRowArgs = {
@@ -7,9 +7,8 @@ import { getPreviousSteps } from '@/workflow/workflow-steps/utils/getWorkflowPre
import { type StepOutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2';
import { filterOutputSchema } from '@/workflow/workflow-variables/utils/filterOutputSchema';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { isDefined, isEmptyObject } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { isEmptyObject } from '~/utils/isEmptyObject';
export const useAvailableVariablesInWorkflowStep = ({
shouldDisplayRecordFields,
+1
View File
@@ -16,6 +16,7 @@ IS_IMAP_SMTP_CALDAV_ENABLED=false
CALENDAR_PROVIDER_GOOGLE_ENABLED=false
MESSAGING_PROVIDER_MICROSOFT_ENABLED=false
CALENDAR_PROVIDER_MICROSOFT_ENABLED=false
TELEMETRY_ENABLED=false
AUTH_GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/redirect
AUTH_GOOGLE_APIS_CALLBACK_URL=http://localhost:3000/auth/google-apis/get-access-token
@@ -0,0 +1,67 @@
import { FieldMetadataType } from 'twenty-shared/types';
export const POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE = '';
export const DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE = '';
export const DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE = {};
export const POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE = '{}';
export const DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE = [];
export const POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE = '{}';
const DEFAULT_ADDRESS_FIELD_NULL_EQUIVALENT_VALUE = {
addressStreet1: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE,
addressStreet2: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE,
addressCity: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE,
addressState: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE,
addressCountry: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE,
addressPostcode: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE,
};
const DEFAULT_EMAILS_FIELD_NULL_EQUIVALENT_VALUE = {
primaryEmail: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE,
additionalEmails: DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE,
};
const DEFAULT_LINKS_FIELD_NULL_EQUIVALENT_VALUE = {
primaryLinkUrl: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE,
primaryLinkLabel: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE,
secondaryLinks: DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE,
};
const DEFAULT_PHONES_FIELD_NULL_EQUIVALENT_VALUE = {
primaryPhoneNumber: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE,
primaryPhoneCountryCode: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE,
primaryPhoneCallingCode: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE,
additionalPhones: DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE,
};
const DEFAULT_FULL_NAME_FIELD_NULL_EQUIVALENT_VALUE = {
firstName: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE,
lastName: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE,
};
const DEFAULT_ACTOR_FIELD_NULL_EQUIVALENT_VALUE = {
name: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE,
context: DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE,
};
const DEFAULT_RICH_TEXT_V2_FIELD_NULL_EQUIVALENT_VALUE = {
markdown: DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE,
};
export const DEFAULT_COMPOSITE_FIELDS_NULL_EQUIVALENT_VALUE: Partial<
Record<FieldMetadataType, Record<string, unknown>>
> = {
[FieldMetadataType.ADDRESS]: DEFAULT_ADDRESS_FIELD_NULL_EQUIVALENT_VALUE,
[FieldMetadataType.EMAILS]: DEFAULT_EMAILS_FIELD_NULL_EQUIVALENT_VALUE,
[FieldMetadataType.LINKS]: DEFAULT_LINKS_FIELD_NULL_EQUIVALENT_VALUE,
[FieldMetadataType.PHONES]: DEFAULT_PHONES_FIELD_NULL_EQUIVALENT_VALUE,
[FieldMetadataType.FULL_NAME]: DEFAULT_FULL_NAME_FIELD_NULL_EQUIVALENT_VALUE,
[FieldMetadataType.ACTOR]: DEFAULT_ACTOR_FIELD_NULL_EQUIVALENT_VALUE,
[FieldMetadataType.RICH_TEXT_V2]:
DEFAULT_RICH_TEXT_V2_FIELD_NULL_EQUIVALENT_VALUE,
};
@@ -44,6 +44,8 @@ import {
CommonQueryRunnerExceptionCode,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import { transformEmailsValue } from 'src/engine/core-modules/record-transformer/utils/transform-emails-value.util';
import { transformLinksValue } from 'src/engine/core-modules/record-transformer/utils/transform-links-value.util';
@@ -55,7 +57,10 @@ import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/typ
@Injectable()
export class DataArgProcessor {
constructor(private readonly recordPositionService: RecordPositionService) {}
constructor(
private readonly recordPositionService: RecordPositionService,
private readonly featureFlagService: FeatureFlagService,
) {}
async process({
partialRecordInputs,
@@ -76,6 +81,12 @@ export class DataArgProcessor {
assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError);
const isNullEquivalenceEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_NULL_EQUIVALENCE_ENABLED,
workspace.id,
);
const processedRecords: Partial<ObjectRecord>[] = [];
for (const record of partialRecordInputs) {
@@ -115,6 +126,7 @@ export class DataArgProcessor {
fieldMetadata,
key,
value,
isNullEquivalenceEnabled,
);
}
processedRecords.push(processedRecord);
@@ -139,6 +151,7 @@ export class DataArgProcessor {
fieldMetadata: FieldMetadataEntity,
key: string,
value: unknown,
isNullEquivalenceEnabled: boolean,
): Promise<unknown> {
switch (fieldMetadata.type) {
case FieldMetadataType.POSITION:
@@ -154,7 +167,7 @@ export class DataArgProcessor {
case FieldMetadataType.TEXT: {
const validatedValue = validateTextFieldOrThrow(value, key);
return transformTextField(validatedValue);
return transformTextField(validatedValue, isNullEquivalenceEnabled);
}
case FieldMetadataType.DATE_TIME:
case FieldMetadataType.DATE:
@@ -179,19 +192,19 @@ export class DataArgProcessor {
fieldMetadata.options?.map((option) => option.value),
);
return transformArrayField(validatedValue);
return transformArrayField(validatedValue, isNullEquivalenceEnabled);
}
case FieldMetadataType.UUID:
return validateUUIDFieldOrThrow(value, key);
case FieldMetadataType.ARRAY: {
const validatedValue = validateArrayFieldOrThrow(value, key);
return transformArrayField(validatedValue);
return transformArrayField(validatedValue, isNullEquivalenceEnabled);
}
case FieldMetadataType.RAW_JSON: {
const validatedValue = validateRawJsonFieldOrThrow(value, key);
return transformRawJsonField(validatedValue);
return transformRawJsonField(validatedValue, isNullEquivalenceEnabled);
}
case FieldMetadataType.RELATION:
case FieldMetadataType.MORPH_RELATION: {
@@ -222,18 +235,18 @@ export class DataArgProcessor {
case FieldMetadataType.EMAILS: {
const validatedValue = validateEmailsFieldOrThrow(value, key);
return transformEmailsValue(validatedValue);
return transformEmailsValue(validatedValue, isNullEquivalenceEnabled);
}
case FieldMetadataType.FULL_NAME: {
const validatedValue = validateFullNameFieldOrThrow(value, key);
return transformFullNameField(validatedValue);
return transformFullNameField(validatedValue, isNullEquivalenceEnabled);
}
case FieldMetadataType.ADDRESS: {
const validatedValue = validateAddressFieldOrThrow(value, key);
return transformAddressField(validatedValue);
return transformAddressField(validatedValue, isNullEquivalenceEnabled);
}
case FieldMetadataType.CURRENCY: {
const validatedValue = validateCurrencyFieldOrThrow(value, key);
@@ -243,7 +256,7 @@ export class DataArgProcessor {
case FieldMetadataType.ACTOR: {
const validatedValue = validateActorFieldOrThrow(value, key);
return transformActorField(validatedValue);
return transformActorField(validatedValue, isNullEquivalenceEnabled);
}
case FieldMetadataType.RICH_TEXT_V2: {
const validatedValue = validateRichTextV2FieldOrThrow(value, key);
@@ -2,16 +2,21 @@ import { isNull, isUndefined } from '@sniptt/guards';
import { type FieldActorSource } from 'twenty-shared/types';
import { transformRawJsonField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-raw-json-field.util';
import { transformTextField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-text-field.util';
export const transformActorField = (
value: {
source?: FieldActorSource | null;
context?: object | string | null;
name?: string | null;
workspaceMemberId?: string | null;
} | null,
isNullEquivalenceEnabled: boolean = false,
): {
source?: FieldActorSource | null;
context?: object | string | null;
name?: string | null;
workspaceMemberId?: string | null;
} | null => {
if (isNull(value)) return null;
@@ -20,5 +25,11 @@ export const transformActorField = (
context: isUndefined(value.context)
? undefined
: transformRawJsonField(value.context, isNullEquivalenceEnabled),
name: isUndefined(value.name)
? undefined
: transformTextField(value.name, isNullEquivalenceEnabled),
workspaceMemberId: isUndefined(value.workspaceMemberId)
? undefined
: value.workspaceMemberId,
};
};
@@ -1,4 +1,4 @@
import { isNull } from '@sniptt/guards';
import { isNullEquivalentArrayFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-array-field-value.util';
export const transformArrayField = (
value: string | string[] | null,
@@ -6,9 +6,7 @@ export const transformArrayField = (
): string[] | null => {
if (typeof value === 'string') return [value];
return isNullEquivalenceEnabled &&
!isNull(value) &&
Object.keys(value).length === 0
return isNullEquivalenceEnabled && isNullEquivalentArrayFieldValue(value)
? null
: value;
};
@@ -1,13 +1,11 @@
//Json.parse() for RawJsonField is done in formatFieldMetadataValue in ORM
import { isNull } from '@sniptt/guards';
import { isNullEquivalentRawJsonFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-raw-json-field-value.util';
export const transformRawJsonField = (
value: object | string | null,
isNullEquivalenceEnabled: boolean = false,
): object | string | null => {
return isNullEquivalenceEnabled &&
!isNull(value) &&
Object.keys(value).length === 0
return isNullEquivalenceEnabled && isNullEquivalentRawJsonFieldValue(value)
? null
: value;
};
@@ -1,8 +1,10 @@
import { isNonEmptyString } from '@sniptt/guards';
import { isNullEquivalentTextFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-text-field-value.util';
export const transformTextField = (
value: string | null,
isNullEquivalenceEnabled: boolean = false,
): string | null => {
return isNullEquivalenceEnabled && !isNonEmptyString(value) ? null : value;
return isNullEquivalenceEnabled && isNullEquivalentTextFieldValue(value)
? null
: value;
};
@@ -0,0 +1,256 @@
import { FieldMetadataType } from 'twenty-shared/types';
import {
POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE,
POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE,
POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE,
} from 'src/engine/api/common/common-args-processors/data-arg-processor/constants/null-equivalent-values.constant';
import { findPostgresDefaultNullEquivalentValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/find-postgres-default-null-equivalent-value.util';
describe('findPostgresDefaultNullEquivalentValue', () => {
describe('Simple Types', () => {
describe('TEXT', () => {
it('should return POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE for null', () => {
expect(
findPostgresDefaultNullEquivalentValue(null, FieldMetadataType.TEXT),
).toBe(POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE);
});
it('should return POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE for empty string', () => {
expect(
findPostgresDefaultNullEquivalentValue('', FieldMetadataType.TEXT),
).toBe(POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE);
});
it("should return POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE for 'NULL'", () => {
expect(
findPostgresDefaultNullEquivalentValue(
'NULL',
FieldMetadataType.TEXT,
),
).toBe(POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE);
});
it('should return undefined for non-null equivalent value', () => {
expect(
findPostgresDefaultNullEquivalentValue(
'value',
FieldMetadataType.TEXT,
),
).toBeUndefined();
});
});
describe('RAW_JSON', () => {
it('should return POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE for null', () => {
expect(
findPostgresDefaultNullEquivalentValue(
null,
FieldMetadataType.RAW_JSON,
),
).toBe(POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE);
});
it('should return POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE for empty object', () => {
expect(
findPostgresDefaultNullEquivalentValue(
{},
FieldMetadataType.RAW_JSON,
),
).toBe(POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE);
});
it("should return POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE for 'NULL'", () => {
expect(
findPostgresDefaultNullEquivalentValue(
'NULL',
FieldMetadataType.RAW_JSON,
),
).toBe(POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE);
});
});
describe('ARRAY', () => {
it('should return POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE for null', () => {
expect(
findPostgresDefaultNullEquivalentValue(null, FieldMetadataType.ARRAY),
).toBe(POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE);
});
it('should return POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE for empty array', () => {
expect(
findPostgresDefaultNullEquivalentValue([], FieldMetadataType.ARRAY),
).toBe(POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE);
});
it("should return POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE for 'NULL'", () => {
expect(
findPostgresDefaultNullEquivalentValue(
'NULL',
FieldMetadataType.ARRAY,
),
).toBe(POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE);
});
});
});
describe('ACTOR', () => {
it('should return text default for name', () => {
expect(
findPostgresDefaultNullEquivalentValue(
'',
FieldMetadataType.ACTOR,
'name',
),
).toBe(POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE);
});
it('should return json default for context', () => {
expect(
findPostgresDefaultNullEquivalentValue(
{},
FieldMetadataType.ACTOR,
'context',
),
).toBe(POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE);
});
});
describe('ADDRESS', () => {
it.each([
'addressStreet1',
'addressStreet2',
'addressCity',
'addressState',
'addressPostcode',
'addressCountry',
])('should return text default for %s', (key) => {
expect(
findPostgresDefaultNullEquivalentValue(
'',
FieldMetadataType.ADDRESS,
key,
),
).toBe(POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE);
});
});
describe('EMAILS', () => {
it('should return text default for primaryEmail', () => {
expect(
findPostgresDefaultNullEquivalentValue(
'',
FieldMetadataType.EMAILS,
'primaryEmail',
),
).toBe(POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE);
});
it('should return array default for additionalEmails', () => {
expect(
findPostgresDefaultNullEquivalentValue(
[],
FieldMetadataType.EMAILS,
'additionalEmails',
),
).toBe(POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE);
});
});
describe('LINKS', () => {
it('should return text default for primaryLinkUrl', () => {
expect(
findPostgresDefaultNullEquivalentValue(
'',
FieldMetadataType.LINKS,
'primaryLinkUrl',
),
).toBe(POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE);
});
it('should return array default for secondaryLinks', () => {
expect(
findPostgresDefaultNullEquivalentValue(
[],
FieldMetadataType.LINKS,
'secondaryLinks',
),
).toBe(POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE);
});
});
describe('PHONES', () => {
it('should return text default for primaryPhoneNumber', () => {
expect(
findPostgresDefaultNullEquivalentValue(
'',
FieldMetadataType.PHONES,
'primaryPhoneNumber',
),
).toBe(POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE);
});
it('should return array default for additionalPhones', () => {
expect(
findPostgresDefaultNullEquivalentValue(
[],
FieldMetadataType.PHONES,
'additionalPhones',
),
).toBe(POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE);
});
});
describe('RICH_TEXT_V2', () => {
it('should return json default for blocknote', () => {
expect(
findPostgresDefaultNullEquivalentValue(
{},
FieldMetadataType.RICH_TEXT_V2,
'blocknote',
),
).toBe(POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE);
});
it('should return text default for markdown', () => {
expect(
findPostgresDefaultNullEquivalentValue(
'',
FieldMetadataType.RICH_TEXT_V2,
'markdown',
),
).toBe(POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE);
});
});
describe('FULL_NAME', () => {
it.each(['firstName', 'lastName'])(
'should return text default for %s',
(key) => {
expect(
findPostgresDefaultNullEquivalentValue(
'',
FieldMetadataType.FULL_NAME,
key,
),
).toBe(POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE);
},
);
});
});
it('should return undefined for unknown type', () => {
expect(
findPostgresDefaultNullEquivalentValue(null, 'UNKNOWN' as any),
).toBeUndefined();
});
it('should return undefined for unknown composite key', () => {
expect(
findPostgresDefaultNullEquivalentValue(
'',
FieldMetadataType.ACTOR,
'unknown',
),
).toBeUndefined();
});
@@ -0,0 +1,42 @@
import { isNullEquivalentArrayFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-array-field-value.util';
describe('isNullEquivalentArrayFieldValue', () => {
describe('null-equivalent values', () => {
it('should return true when value is null', () => {
const result = isNullEquivalentArrayFieldValue(null);
expect(result).toBe(true);
});
it('should return true when value is an empty array', () => {
const result = isNullEquivalentArrayFieldValue([]);
expect(result).toBe(true);
});
});
describe('non-null-equivalent values', () => {
it('should return false when value is undefined', () => {
const result = isNullEquivalentArrayFieldValue(undefined);
expect(result).toBe(false);
});
it('should return false when value is an array with one item', () => {
const result = isNullEquivalentArrayFieldValue(['item']);
expect(result).toBe(false);
});
it('should return false when value is a string', () => {
const result = isNullEquivalentArrayFieldValue('hello');
expect(result).toBe(false);
});
it('should return false when value is an empty string', () => {
const result = isNullEquivalentArrayFieldValue('');
expect(result).toBe(false);
});
});
});
@@ -0,0 +1,48 @@
import { isNullEquivalentRawJsonFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-raw-json-field-value.util';
describe('isNullEquivalentRawJsonFieldValue', () => {
describe('null-equivalent values', () => {
it('should return true when value is null', () => {
const result = isNullEquivalentRawJsonFieldValue(null);
expect(result).toBe(true);
});
it('should return true when value is an empty object', () => {
const result = isNullEquivalentRawJsonFieldValue({});
expect(result).toBe(true);
});
it('should return true when value is an empty array', () => {
const result = isNullEquivalentRawJsonFieldValue([]);
expect(result).toBe(true);
});
});
describe('non-null-equivalent values', () => {
it('should return false when value is undefined', () => {
const result = isNullEquivalentRawJsonFieldValue(undefined);
expect(result).toBe(false);
});
it('should return false when value is an object with properties', () => {
const result = isNullEquivalentRawJsonFieldValue({ key: 'value' });
expect(result).toBe(false);
});
it('should return false when value is a string', () => {
const result = isNullEquivalentRawJsonFieldValue('hello');
expect(result).toBe(false);
});
it('should return false when value is an empty string', () => {
const result = isNullEquivalentRawJsonFieldValue('');
expect(result).toBe(false);
});
});
});
@@ -0,0 +1,31 @@
import { isNullEquivalentTextFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-text-field-value.util';
describe('isNullEquivalentTextFieldValue', () => {
describe('null-equivalent values', () => {
it('should return true when value is an empty string', () => {
const result = isNullEquivalentTextFieldValue('');
expect(result).toBe(true);
});
it('should return true when value is null', () => {
const result = isNullEquivalentTextFieldValue(null);
expect(result).toBe(true);
});
});
describe('non-null-equivalent values', () => {
it('should return false when value is a non-empty string', () => {
const result = isNullEquivalentTextFieldValue('hello');
expect(result).toBe(false);
});
it('should return false when value is undefined', () => {
const result = isNullEquivalentTextFieldValue(undefined);
expect(result).toBe(false);
});
});
});
@@ -0,0 +1,160 @@
import { FieldMetadataType } from 'twenty-shared/types';
import {
POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE,
POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE,
POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE,
} from 'src/engine/api/common/common-args-processors/data-arg-processor/constants/null-equivalent-values.constant';
import { isNullEquivalentArrayFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-array-field-value.util';
import { isNullEquivalentRawJsonFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-raw-json-field-value.util';
import { isNullEquivalentTextFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-text-field-value.util';
export const findPostgresDefaultNullEquivalentValue = (
value: unknown,
fieldMetadataType: FieldMetadataType,
key?: string,
) => {
switch (fieldMetadataType) {
case FieldMetadataType.TEXT:
return isNullEquivalentTextFieldValue(value) || value === 'NULL'
? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case FieldMetadataType.RAW_JSON:
return isNullEquivalentRawJsonFieldValue(value) || value === 'NULL'
? POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case FieldMetadataType.ARRAY:
return isNullEquivalentArrayFieldValue(value) || value === 'NULL'
? POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case FieldMetadataType.ACTOR: {
switch (key) {
case 'name':
return isNullEquivalentTextFieldValue(value) || value === 'NULL'
? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'context':
return isNullEquivalentRawJsonFieldValue(value) || value === 'NULL'
? POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
default:
return undefined;
}
}
case FieldMetadataType.ADDRESS: {
switch (key) {
case 'addressStreet1':
return isNullEquivalentTextFieldValue(value) || value === 'NULL'
? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'addressStreet2':
return isNullEquivalentTextFieldValue(value) || value === 'NULL'
? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'addressCity':
return isNullEquivalentTextFieldValue(value) || value === 'NULL'
? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'addressState':
return isNullEquivalentTextFieldValue(value) || value === 'NULL'
? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'addressPostcode':
return isNullEquivalentTextFieldValue(value) || value === 'NULL'
? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'addressCountry':
return isNullEquivalentTextFieldValue(value) || value === 'NULL'
? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
default:
return undefined;
}
}
case FieldMetadataType.EMAILS: {
switch (key) {
case 'primaryEmail':
return isNullEquivalentTextFieldValue(value) || value === 'NULL'
? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'additionalEmails':
return isNullEquivalentArrayFieldValue(value) || value === 'NULL'
? POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
default:
return undefined;
}
}
case FieldMetadataType.LINKS: {
switch (key) {
case 'primaryLinkUrl':
return isNullEquivalentTextFieldValue(value) || value === 'NULL'
? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'primaryLinkLabel':
return isNullEquivalentTextFieldValue(value) || value === 'NULL'
? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'secondaryLinks':
return isNullEquivalentArrayFieldValue(value) || value === 'NULL'
? POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
default:
return undefined;
}
}
case FieldMetadataType.PHONES: {
switch (key) {
case 'primaryPhoneNumber':
return isNullEquivalentTextFieldValue(value) || value === 'NULL'
? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'primaryPhoneCountryCode':
return isNullEquivalentTextFieldValue(value) || value === 'NULL'
? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'primaryPhoneCallingCode':
return isNullEquivalentTextFieldValue(value) || value === 'NULL'
? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'additionalPhones':
return isNullEquivalentArrayFieldValue(value) || value === 'NULL'
? POSTGRES_DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
default:
return undefined;
}
}
case FieldMetadataType.RICH_TEXT_V2: {
switch (key) {
case 'blocknote':
return isNullEquivalentRawJsonFieldValue(value) || value === 'NULL'
? POSTGRES_DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'markdown':
return isNullEquivalentTextFieldValue(value) || value === 'NULL'
? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
default:
return undefined;
}
}
case FieldMetadataType.FULL_NAME: {
switch (key) {
case 'firstName':
return isNullEquivalentTextFieldValue(value) || value === 'NULL'
? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
case 'lastName':
return isNullEquivalentTextFieldValue(value) || value === 'NULL'
? POSTGRES_DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
: undefined;
default:
return undefined;
}
}
}
return undefined;
};
@@ -0,0 +1,3 @@
export const isNullEquivalentArrayFieldValue = (value: unknown): boolean => {
return (Array.isArray(value) && value.length === 0) || value === null;
};
@@ -0,0 +1,8 @@
import { isNull } from '@sniptt/guards';
import { isEmptyObject } from 'twenty-shared/utils';
export const isNullEquivalentRawJsonFieldValue = (value: unknown): boolean => {
if (isNull(value)) return true;
return isEmptyObject(value);
};
@@ -0,0 +1,12 @@
import { isNull } from '@sniptt/guards';
import { DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE } from 'src/engine/api/common/common-args-processors/data-arg-processor/constants/null-equivalent-values.constant';
export const isNullEquivalentTextFieldValue = (value: unknown): boolean => {
if (isNull(value)) return true;
return (
typeof value === 'string' &&
value === DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE
);
};
@@ -3,6 +3,8 @@ import { FieldActorSource } from 'twenty-shared/types';
import { validateRatingAndSelectFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-rating-and-select-field-or-throw.util';
import { validateRawJsonFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-raw-json-field-or-throw.util';
import { validateTextFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-text-field-or-throw.util';
import { validateUUIDFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-uuid-field-or-throw.util';
import {
CommonQueryRunnerException,
CommonQueryRunnerExceptionCode,
@@ -28,6 +30,12 @@ export const validateActorFieldOrThrow = (
case 'context':
validateRawJsonFieldOrThrow(subFieldValue, `${fieldName}.${subField}`);
break;
case 'name':
validateTextFieldOrThrow(subFieldValue, `${fieldName}.${subField}`);
break;
case 'workspaceMemberId':
validateUUIDFieldOrThrow(subFieldValue, `${fieldName}.${subField}`);
break;
default:
throw new CommonQueryRunnerException(
`Invalid subfield ${subField} for actor field "${fieldName}"`,
@@ -1,6 +1,6 @@
import { compositeTypeDefinitions } from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
import { type WhereExpressionBuilder } from 'typeorm';
import { compositeTypeDefinitions } from 'twenty-shared/types';
import {
GraphqlQueryRunnerException,
@@ -60,12 +60,12 @@ export class GraphqlQueryFilterFieldParser {
GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
);
}
const { sql, params } = computeWhereConditionParts({
operator,
objectNameSingular,
key,
value,
fieldMetadataType: fieldMetadata.type,
});
if (isFirst) {
@@ -125,7 +125,9 @@ export class GraphqlQueryFilterFieldParser {
operator,
objectNameSingular,
key: fullFieldName,
subFieldKey,
value,
fieldMetadataType: fieldMetadata.type,
});
if (isFirst && index === 0) {
@@ -1,5 +1,8 @@
import { type FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type ObjectLiteral } from 'typeorm';
import { findPostgresDefaultNullEquivalentValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/find-postgres-default-null-equivalent-value.util';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
@@ -15,30 +18,45 @@ export const computeWhereConditionParts = ({
operator,
objectNameSingular,
key,
subFieldKey,
value,
fieldMetadataType,
}: {
operator: string;
objectNameSingular: string;
key: string;
subFieldKey?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any;
fieldMetadataType: FieldMetadataType;
}): WhereConditionParts => {
const uuid = Math.random().toString(36).slice(2, 7);
const secondUuid = Math.random().toString(36).slice(2, 7);
//TODO : Remove filter null equivalence injection once feature flag removed + null equivalence transformation added in ORM
const nullEquivalentFieldValue = findPostgresDefaultNullEquivalentValue(
value,
fieldMetadataType,
subFieldKey,
);
const hasNullEquivalentFieldValue = isDefined(nullEquivalentFieldValue);
switch (operator) {
case 'isEmptyArray':
return {
sql: `"${objectNameSingular}"."${key}" = '{}'`,
sql: `"${objectNameSingular}"."${key}" = '{}'${hasNullEquivalentFieldValue ? ` OR "${objectNameSingular}"."${key}" IS NULL` : ''}`,
params: {},
};
case 'eq':
return {
sql: `"${objectNameSingular}"."${key}" = :${key}${uuid}`,
sql: `"${objectNameSingular}"."${key}" = :${key}${uuid}${hasNullEquivalentFieldValue ? ` OR "${objectNameSingular}"."${key}" IS NULL` : ''}`,
params: { [`${key}${uuid}`]: value },
};
case 'neq':
return {
sql: `"${objectNameSingular}"."${key}" != :${key}${uuid}`,
sql: `"${objectNameSingular}"."${key}" != :${key}${uuid}${hasNullEquivalentFieldValue ? ` OR "${objectNameSingular}"."${key}" IS NOT NULL` : ''}`,
params: { [`${key}${uuid}`]: value },
};
case 'gt':
@@ -68,17 +86,19 @@ export const computeWhereConditionParts = ({
};
case 'is':
return {
sql: `"${objectNameSingular}"."${key}" IS ${value === 'NULL' ? 'NULL' : 'NOT NULL'}`,
params: {},
sql: `"${objectNameSingular}"."${key}" IS ${value === 'NULL' ? 'NULL' : 'NOT NULL'}${hasNullEquivalentFieldValue ? ` OR "${objectNameSingular}"."${key}" = :${key}${secondUuid}` : ''}`,
params: hasNullEquivalentFieldValue
? { [`${key}${secondUuid}`]: nullEquivalentFieldValue }
: {},
};
case 'like':
return {
sql: `"${objectNameSingular}"."${key}"::text LIKE :${key}${uuid}`,
sql: `"${objectNameSingular}"."${key}"::text LIKE :${key}${uuid}${hasNullEquivalentFieldValue ? ` OR "${objectNameSingular}"."${key}" IS NULL` : ''}`,
params: { [`${key}${uuid}`]: `${value}` },
};
case 'ilike':
return {
sql: `"${objectNameSingular}"."${key}"::text ILIKE :${key}${uuid}`,
sql: `"${objectNameSingular}"."${key}"::text ILIKE :${key}${uuid}${hasNullEquivalentFieldValue ? ` OR "${objectNameSingular}"."${key}" IS NULL` : ''}`,
params: { [`${key}${uuid}`]: `${value}` },
};
case 'startsWith':
@@ -16,4 +16,5 @@ export enum FeatureFlagKey {
IS_WORKFLOW_RUN_STOPPAGE_ENABLED = 'IS_WORKFLOW_RUN_STOPPAGE_ENABLED',
IS_DASHBOARD_V2_ENABLED = 'IS_DASHBOARD_V2_ENABLED',
IS_GLOBAL_WORKSPACE_DATASOURCE_ENABLED = 'IS_GLOBAL_WORKSPACE_DATASOURCE_ENABLED',
IS_NULL_EQUIVALENCE_ENABLED = 'IS_NULL_EQUIVALENCE_ENABLED',
}
@@ -128,11 +128,13 @@ export class UpsertRecordService {
? conflictPathsUniqueFieldsToUpdate
: ['id'];
//TODO : To delete once IS_NULL_EQUIVALENCE_ENABLED feature flag removed
const indexPredicate = uniqueFieldsToUpdate
.map((field) =>
computeUniqueIndexWhereClause({
type: field.type,
name: field.name,
defaultValue: field.defaultValue,
}),
)
.filter(isDefined);
@@ -1,15 +1,21 @@
import { isNonEmptyString } from '@sniptt/guards';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const transformEmailsValue = (value: any): any => {
export const transformEmailsValue = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any,
isNullEquivalenceEnabled: boolean = false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): any => {
if (!value) {
return value;
}
let additionalEmails = value?.additionalEmails;
let additionalEmails: string | null = value?.additionalEmails;
const primaryEmail = value?.primaryEmail
? value.primaryEmail.toLowerCase()
: '';
: isNullEquivalenceEnabled
? null
: '';
if (additionalEmails) {
try {
@@ -22,6 +28,13 @@ export const transformEmailsValue = (value: any): any => {
additionalEmails = JSON.stringify(
emailArray.map((email) => email.toLowerCase()),
);
if (isNullEquivalenceEnabled) {
additionalEmails =
Array.isArray(emailArray) && emailArray.length === 0
? null
: additionalEmails;
}
} catch {
/* empty */
}
@@ -1,11 +1,11 @@
import { isNonEmptyString } from '@sniptt/guards';
import isEmpty from 'lodash.isempty';
import { type LinkMetadataNullable } from 'twenty-shared/types';
import {
isDefined,
lowercaseUrlOriginAndRemoveTrailingSlash,
parseJson,
} from 'twenty-shared/utils';
import { type LinkMetadataNullable } from 'twenty-shared/types';
import { removeEmptyLinks } from 'src/engine/core-modules/record-transformer/utils/remove-empty-links';
@@ -5,6 +5,10 @@ import {
parsePhoneNumberWithError,
} from 'libphonenumber-js';
import isEmpty from 'lodash.isempty';
import {
type AdditionalPhoneMetadata,
type PhonesMetadata,
} from 'twenty-shared/types';
import {
getCountryCodesForCallingCode,
isDefined,
@@ -12,10 +16,6 @@ import {
parseJson,
removeUndefinedFields,
} from 'twenty-shared/utils';
import {
type AdditionalPhoneMetadata,
type PhonesMetadata,
} from 'twenty-shared/types';
import {
RecordTransformerException,
@@ -1,22 +1,12 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { generateNullable } from 'src/engine/metadata-modules/field-metadata/utils/generate-nullable';
describe('generateNullable', () => {
it('should generate a nullable value false for TEXT, EMAIL, PHONE no matter what the input is', () => {
expect(generateNullable(FieldMetadataType.TEXT, false)).toEqual(false);
expect(generateNullable(FieldMetadataType.TEXT, true)).toEqual(false);
expect(generateNullable(FieldMetadataType.TEXT)).toEqual(false);
});
it('should should return true if no input is given', () => {
expect(generateNullable(FieldMetadataType.DATE_TIME)).toEqual(true);
expect(generateNullable()).toEqual(true);
});
it('should should return the input value if the input value is given', () => {
expect(generateNullable(FieldMetadataType.DATE_TIME, true)).toEqual(true);
expect(generateNullable(FieldMetadataType.DATE_TIME, false)).toEqual(false);
expect(generateNullable(true)).toEqual(true);
expect(generateNullable(false)).toEqual(false);
});
});
@@ -1,35 +0,0 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { isValidUniqueFieldDefaultValueCombination } from 'src/engine/metadata-modules/field-metadata/utils/is-valid-unique-input.util';
describe('isValidUniqueFieldDefaultValueCombination', () => {
it('should return true if the field has a custom default value and is not unique', () => {
const result = isValidUniqueFieldDefaultValueCombination({
defaultValue: "'custom value'",
isUnique: false,
type: FieldMetadataType.TEXT,
});
expect(result).toBe(true);
});
it('should return true if the field has standard default value and is unique', () => {
const result = isValidUniqueFieldDefaultValueCombination({
defaultValue: "''",
isUnique: true,
type: FieldMetadataType.TEXT,
});
expect(result).toBe(true);
});
it('should return false if the field has custom default value and is unique', () => {
const result = isValidUniqueFieldDefaultValueCombination({
defaultValue: "'custom value'",
isUnique: true,
type: FieldMetadataType.TEXT,
});
expect(result).toBe(false);
});
});
@@ -1,4 +1,4 @@
import { FieldMetadataType, FieldActorSource } from 'twenty-shared/types';
import { FieldActorSource, FieldMetadataType } from 'twenty-shared/types';
import { type FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
@@ -7,47 +7,6 @@ export function generateDefaultValue(
type: FieldMetadataType,
): FieldMetadataDefaultValue {
switch (type) {
case FieldMetadataType.TEXT:
return "''" satisfies FieldMetadataDefaultValue<FieldMetadataType.TEXT>;
case FieldMetadataType.EMAILS:
return {
primaryEmail: "''",
additionalEmails: null,
} satisfies FieldMetadataDefaultValue<FieldMetadataType.EMAILS>;
case FieldMetadataType.FULL_NAME:
return {
firstName: "''",
lastName: "''",
} satisfies FieldMetadataDefaultValue<FieldMetadataType.FULL_NAME>;
case FieldMetadataType.ADDRESS:
return {
addressStreet1: "''",
addressStreet2: "''",
addressCity: "''",
addressState: "''",
addressCountry: "''",
addressPostcode: "''",
addressLat: null,
addressLng: null,
} satisfies FieldMetadataDefaultValue<FieldMetadataType.ADDRESS>;
case FieldMetadataType.CURRENCY:
return {
amountMicros: null,
currencyCode: "''",
} satisfies FieldMetadataDefaultValue<FieldMetadataType.CURRENCY>;
case FieldMetadataType.LINKS:
return {
primaryLinkLabel: "''",
primaryLinkUrl: "''",
secondaryLinks: null,
} satisfies FieldMetadataDefaultValue<FieldMetadataType.LINKS>;
case FieldMetadataType.PHONES:
return {
primaryPhoneNumber: "''",
primaryPhoneCountryCode: "''",
primaryPhoneCallingCode: "''",
additionalPhones: null,
} satisfies FieldMetadataDefaultValue<FieldMetadataType.PHONES>;
case FieldMetadataType.ACTOR:
return {
source: `'${FieldActorSource.MANUAL}'`,
@@ -1,6 +1,4 @@
import { FieldMetadataType } from 'twenty-shared/types';
export function generateNullable(
type: FieldMetadataType,
inputNullableValue?: boolean,
isRemoteCreation?: boolean,
): boolean {
@@ -8,10 +6,5 @@ export function generateNullable(
return true;
}
switch (type) {
case FieldMetadataType.TEXT:
return false;
default:
return inputNullableValue ?? true;
}
return inputNullableValue ?? true;
}
@@ -1,38 +0,0 @@
import {
compositeTypeDefinitions,
type FieldMetadataType,
} from 'twenty-shared/types';
import { type FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { generateDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/generate-default-value';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
export const isValidUniqueFieldDefaultValueCombination = ({
defaultValue,
isUnique,
type,
}: {
defaultValue: FieldMetadataDefaultValue;
isUnique: boolean;
type: FieldMetadataType;
}) => {
if (!isUnique) return true;
const defaultDefaultValue = generateDefaultValue(type);
if (!isCompositeFieldMetadataType(type))
return defaultValue === defaultDefaultValue;
const doUniquePropertiesHaveDefaultValues =
compositeTypeDefinitions
.get(type)
?.properties.filter((property) => property.isIncludedInUniqueConstraint)
.every(
({ name }) =>
(defaultValue as Record<string, string | null>)?.[name] ===
(defaultDefaultValue as Record<string, string | null>)?.[name],
) ?? false;
return doUniquePropertiesHaveDefaultValues;
};
@@ -27,7 +27,6 @@ export const prepareCustomFieldMetadataForCreation = (
objectMetadataId: fieldMetadataInput.objectMetadataId,
workspaceId: fieldMetadataInput.workspaceId,
isNullable: generateNullable(
fieldMetadataInput.type,
fieldMetadataInput.isNullable,
fieldMetadataInput.isRemoteCreation,
),
@@ -34,7 +34,6 @@ export const getDefaultFlatFieldMetadata = ({
isCustom: true,
isLabelSyncedWithName: createFieldInput.isLabelSyncedWithName ?? false,
isNullable: generateNullable(
createFieldInput.type,
createFieldInput.isNullable,
createFieldInput.isRemoteCreation,
),
@@ -3,9 +3,9 @@ import { InjectRepository } from '@nestjs/typeorm';
import isEmpty from 'lodash.isempty';
import {
type FieldMetadataType,
type CompositeType,
compositeTypeDefinitions,
type FieldMetadataType,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { In, type QueryRunner, Repository } from 'typeorm';
@@ -19,7 +19,6 @@ import {
} from 'src/engine/metadata-modules/index-metadata/index-field-metadata.exception';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { type IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types';
import { computeUniqueIndexWhereClause } from 'src/engine/metadata-modules/index-metadata/utils/compute-unique-index-where-clause.util';
import { generateDeterministicIndexName } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
@@ -197,7 +196,6 @@ export class IndexMetadataService {
computeObjectTargetTable(objectMetadata),
updatedFieldMetadata.name,
])}`,
indexWhereClause: computeUniqueIndexWhereClause(updatedFieldMetadata),
});
return {
@@ -1,45 +0,0 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { computeUniqueIndexWhereClause } from 'src/engine/metadata-modules/index-metadata/utils/compute-unique-index-where-clause.util';
import { getMockFieldMetadataEntity } from 'src/utils/__test__/get-field-metadata-entity.mock';
describe('computeUniqueIndexWhereClause', () => {
it('should return undefined if standard default value is not defined', () => {
const fieldMetadata = getMockFieldMetadataEntity({
workspaceId: 'workspace-id',
objectMetadataId: 'object-id',
type: FieldMetadataType.UUID,
name: 'testField',
});
const result = computeUniqueIndexWhereClause(fieldMetadata);
expect(result).toBeUndefined();
});
it('should return a where clause for a an atomic type field', () => {
const fieldMetadata = getMockFieldMetadataEntity({
workspaceId: 'workspace-id',
objectMetadataId: 'object-id',
type: FieldMetadataType.TEXT,
name: 'testTextField',
});
const result = computeUniqueIndexWhereClause(fieldMetadata);
expect(result).toBe('"testTextField" != \'\'');
});
it('should return a where clause for a composite type field', () => {
const fieldMetadata = getMockFieldMetadataEntity({
workspaceId: 'workspace-id',
objectMetadataId: 'object-id',
type: FieldMetadataType.EMAILS,
name: 'testEmailsField',
});
const result = computeUniqueIndexWhereClause(fieldMetadata);
expect(result).toBe('"testEmailsFieldPrimaryEmail" != \'\'');
});
});
@@ -11,19 +11,19 @@ import {
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { generateDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/generate-default-value';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import {
IndexMetadataException,
IndexMetadataExceptionCode,
} from 'src/engine/metadata-modules/index-metadata/index-field-metadata.exception';
//TODO : To delete once IS_NULL_EQUIVALENCE_ENABLED feature flag removed
export const computeUniqueIndexWhereClause = (
fieldMetadata: Pick<FieldMetadataEntity, 'type' | 'name'>,
fieldMetadata: Pick<FieldMetadataEntity, 'type' | 'name' | 'defaultValue'>,
) => {
const defaultDefaultValue = generateDefaultValue(fieldMetadata.type);
const defaultValue = fieldMetadata.defaultValue;
if (!isDefined(defaultDefaultValue)) return;
if (!isDefined(defaultValue)) return;
if (
fieldMetadata.type === FieldMetadataType.RELATION ||
@@ -36,7 +36,7 @@ export const computeUniqueIndexWhereClause = (
}
if (!isCompositeFieldMetadataType(fieldMetadata.type)) {
return `"${fieldMetadata.name}" != ${defaultDefaultValue}`;
return `"${fieldMetadata.name}" != ${defaultValue}`;
}
const compositeType = compositeTypeDefinitions.get(fieldMetadata.type);
@@ -48,7 +48,7 @@ export const computeUniqueIndexWhereClause = (
);
}
const defaultDefaultValueProperties = Object.keys(defaultDefaultValue);
const defaultDefaultValueProperties = Object.keys(defaultValue);
const columnNamesWithDefaultValues = compositeType.properties
.filter(
@@ -57,13 +57,13 @@ export const computeUniqueIndexWhereClause = (
defaultDefaultValueProperties.includes(property.name),
)
.map((property) => {
const defaultValue =
defaultDefaultValue[property.name as keyof typeof defaultDefaultValue];
const defaultValueProperty =
defaultValue[property.name as keyof typeof defaultValue];
if (isNonEmptyString(defaultValue)) {
if (isNonEmptyString(defaultValueProperty)) {
return [
computeCompositeColumnName(fieldMetadata, property),
defaultValue,
defaultValueProperty,
];
}
})
@@ -140,6 +140,7 @@ describe('WorkspaceEntityManager', () => {
IS_WORKFLOW_RUN_STOPPAGE_ENABLED: false,
IS_DASHBOARD_V2_ENABLED: false,
IS_GLOBAL_WORKSPACE_DATASOURCE_ENABLED: false,
IS_NULL_EQUIVALENCE_ENABLED: false,
},
eventEmitterService: {
emitMutationEvent: jest.fn(),
@@ -167,6 +168,7 @@ describe('WorkspaceEntityManager', () => {
IS_WORKFLOW_RUN_STOPPAGE_ENABLED: false,
IS_DASHBOARD_V2_ENABLED: false,
IS_GLOBAL_WORKSPACE_DATASOURCE_ENABLED: false,
IS_NULL_EQUIVALENCE_ENABLED: false,
},
permissionsPerRoleId: {},
} as WorkspaceDataSource;
@@ -1,11 +1,18 @@
import { isPlainObject } from '@nestjs/common/utils/shared.utils';
import { isNull } from '@sniptt/guards';
import {
FieldMetadataType,
compositeTypeDefinitions,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE,
DEFAULT_COMPOSITE_FIELDS_NULL_EQUIVALENT_VALUE,
DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE,
DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE,
} from 'src/engine/api/common/common-args-processors/data-arg-processor/constants/null-equivalent-values.constant';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
@@ -46,7 +53,11 @@ export function formatResult<T>(
for (const [key, value] of Object.entries(data)) {
const compositePropertyArgs = compositeFieldMetadataMap.get(key);
const fieldMetadataId = objectMetadataItemWithFieldMaps.fieldIdByName[key];
const fieldMetadataId =
objectMetadataItemWithFieldMaps.fieldIdByName[key] ||
objectMetadataItemWithFieldMaps.fieldIdByName[
compositePropertyArgs?.parentField ?? ''
];
const fieldMetadata = objectMetadataItemWithFieldMaps.fieldsById[
fieldMetadataId
@@ -66,7 +77,7 @@ export function formatResult<T>(
);
} else if (fieldMetadata) {
// @ts-expect-error legacy noImplicitAny
newData[key] = formatFieldMetadataValue(value, fieldMetadata);
newData[key] = formatFieldMetadataValue(value, fieldMetadata.type);
} else {
// @ts-expect-error legacy noImplicitAny
newData[key] = value;
@@ -99,7 +110,7 @@ export function formatResult<T>(
);
}
if (!compositePropertyArgs) {
if (!compositePropertyArgs || !isDefined(fieldMetadata)) {
continue;
}
@@ -112,7 +123,13 @@ export function formatResult<T>(
}
// @ts-expect-error legacy noImplicitAny
newData[parentField][compositeProperty.name] = value;
newData[parentField][compositeProperty.name] = isNull(value)
? transformCompositeFieldNullValue(
value,
compositeProperty.name,
fieldMetadata,
)
: value;
}
const fieldMetadataItemsOfTypeDateOnly = Object.values(
@@ -162,17 +179,50 @@ export function getCompositeFieldMetadataMap(
function formatFieldMetadataValue(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any,
fieldMetadata: FieldMetadataEntity,
fieldMetadataType: FieldMetadataType,
) {
if (
typeof value === 'string' &&
(fieldMetadata.type === FieldMetadataType.MULTI_SELECT ||
fieldMetadata.type === FieldMetadataType.ARRAY)
(fieldMetadataType === FieldMetadataType.MULTI_SELECT ||
fieldMetadataType === FieldMetadataType.ARRAY)
) {
const cleanedValue = value.replace(/{|}/g, '').trim();
return cleanedValue ? cleanedValue.split(',') : [];
}
if (isNull(value)) {
if (
fieldMetadataType === FieldMetadataType.MULTI_SELECT ||
fieldMetadataType === FieldMetadataType.ARRAY
) {
return DEFAULT_ARRAY_FIELD_NULL_EQUIVALENT_VALUE;
}
if (fieldMetadataType === FieldMetadataType.RAW_JSON) {
return DEFAULT_RAW_JSON_FIELD_NULL_EQUIVALENT_VALUE;
}
if (fieldMetadataType === FieldMetadataType.TEXT) {
return DEFAULT_TEXT_FIELD_NULL_EQUIVALENT_VALUE;
}
return value;
}
return value;
}
function transformCompositeFieldNullValue(
value: unknown,
compositePropertyName: string,
fieldMetadata: FieldMetadataEntity,
) {
if (!isNull(value)) return value;
return (
DEFAULT_COMPOSITE_FIELDS_NULL_EQUIVALENT_VALUE[fieldMetadata.type]?.[
compositePropertyName
] ?? value
);
}
@@ -46,7 +46,7 @@ describe('WorkspaceMigrationIndexFactory', () => {
expect(firstMigration.indexes[0].columns).toEqual(['simpleField']);
expect(firstMigration.indexes[0].type).toBe('BTREE');
expect(firstMigration.indexes[0].isUnique).toBe(true);
expect(firstMigration.indexes[0].where).toBe('"simpleField" != \'\'');
expect(firstMigration.indexes[0].where).toBeNull();
});
it('should create index migrations for relation fields', async () => {
@@ -1,7 +1,7 @@
import {
FieldMetadataType,
type CompositeType,
compositeTypeDefinitions,
FieldMetadataType,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
@@ -92,17 +92,13 @@ export const createIndexMigration = async (
.flat()
.filter(isDefined);
const defaultWhereClause = indexMetadata.isUnique
? `${columns.map((column) => `"${column}"`).join(" != '' AND ")} != ''`
: null;
return {
name: indexMetadata.name,
action: WorkspaceMigrationIndexActionType.CREATE,
isUnique: indexMetadata.isUnique,
columns,
type: indexMetadata.indexType,
where: indexMetadata.indexWhereClause ?? defaultWhereClause,
where: indexMetadata.indexWhereClause,
};
});
@@ -8,7 +8,6 @@ import {
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { isValidUniqueFieldDefaultValueCombination } from 'src/engine/metadata-modules/field-metadata/utils/is-valid-unique-input.util';
import { FlatEntityMapsExceptionCode } from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { isCompositeFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-composite-flat-field-metadata.util';
@@ -147,11 +146,7 @@ export class FlatIndexValidatorService {
if (flatIndexToValidate.isUnique) {
if (
isDefined(relatedFlatField.defaultValue) &&
!isValidUniqueFieldDefaultValueCombination({
defaultValue: relatedFlatField.defaultValue,
isUnique: relatedFlatField.isUnique ?? false,
type: relatedFlatField.type,
})
relatedFlatField.isUnique
) {
const fieldName = relatedFlatField.name;
const fieldType = relatedFlatField.type;
@@ -1,8 +1,8 @@
import { msg } from '@lingui/core/macro';
import {
ActorMetadata,
FieldMetadataType,
RelationOnDeleteAction,
ActorMetadata,
} from 'twenty-shared/types';
import { STANDARD_OBJECT_IDS } from 'twenty-shared/metadata';
@@ -68,6 +68,7 @@ export class AttachmentWorkspaceEntity extends BaseWorkspaceEntity {
description: msg`Attachment type (deprecated - use fileCategory)`,
icon: 'IconList',
})
@WorkspaceIsNullable()
type: string;
@WorkspaceField({
@@ -1,10 +1,10 @@
import { msg } from '@lingui/core/macro';
import {
FieldMetadataType,
RelationOnDeleteAction,
ActorMetadata,
AddressMetadata,
FieldMetadataType,
LinksMetadata,
RelationOnDeleteAction,
type CurrencyMetadata,
} from 'twenty-shared/types';
import { STANDARD_OBJECT_IDS } from 'twenty-shared/metadata';
@@ -30,8 +30,8 @@ import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-re
import { COMPANY_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-icons';
import {
type FieldTypeAndNameMetadata,
getTsVectorColumnExpressionFromFields,
type FieldTypeAndNameMetadata,
} from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util';
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
@@ -71,6 +71,7 @@ export class CompanyWorkspaceEntity extends BaseWorkspaceEntity {
description: msg`The company name`,
icon: 'IconBuildingSkyscraper',
})
@WorkspaceIsNullable()
name: string;
@WorkspaceField({
@@ -84,6 +85,7 @@ export class CompanyWorkspaceEntity extends BaseWorkspaceEntity {
},
})
@WorkspaceIsUnique()
@WorkspaceIsNullable()
domainName: LinksMetadata;
@WorkspaceField({
@@ -1,8 +1,8 @@
import { msg } from '@lingui/core/macro';
import {
ActorMetadata,
FieldMetadataType,
RelationOnDeleteAction,
ActorMetadata,
type RichTextV2Metadata,
} from 'twenty-shared/types';
import { STANDARD_OBJECT_IDS } from 'twenty-shared/metadata';
@@ -71,6 +71,7 @@ export class NoteWorkspaceEntity extends BaseWorkspaceEntity {
description: msg`Note title`,
icon: 'IconNotes',
})
@WorkspaceIsNullable()
title: string;
@WorkspaceField({
@@ -1,9 +1,9 @@
import { msg } from '@lingui/core/macro';
import {
FieldMetadataType,
RelationOnDeleteAction,
ActorMetadata,
type CurrencyMetadata,
FieldMetadataType,
RelationOnDeleteAction,
} from 'twenty-shared/types';
import { STANDARD_OBJECT_IDS } from 'twenty-shared/metadata';
@@ -63,6 +63,7 @@ export class OpportunityWorkspaceEntity extends BaseWorkspaceEntity {
description: msg`The opportunity name`,
icon: 'IconTargetArrow',
})
@WorkspaceIsNullable()
name: string;
@WorkspaceField({
@@ -1,10 +1,10 @@
import { msg } from '@lingui/core/macro';
import {
FieldMetadataType,
RelationOnDeleteAction,
ActorMetadata,
EmailsMetadata,
FieldMetadataType,
PhonesMetadata,
RelationOnDeleteAction,
type FullNameMetadata,
type LinksMetadata,
} from 'twenty-shared/types';
@@ -31,8 +31,8 @@ import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-re
import { PERSON_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-icons';
import {
type FieldTypeAndNameMetadata,
getTsVectorColumnExpressionFromFields,
type FieldTypeAndNameMetadata,
} from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util';
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
@@ -96,6 +96,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
},
})
@WorkspaceIsUnique()
@WorkspaceIsNullable()
emails: EmailsMetadata;
@WorkspaceField({
@@ -125,6 +126,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
description: msg`Contacts job title`,
icon: 'IconBriefcase',
})
@WorkspaceIsNullable()
jobTitle: string;
@WorkspaceField({
@@ -147,6 +149,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
maxNumberOfValues: 1,
},
})
@WorkspaceIsNullable()
phones: PhonesMetadata;
@WorkspaceField({
@@ -156,6 +159,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
description: msg`Contacts city`,
icon: 'IconMap',
})
@WorkspaceIsNullable()
city: string;
@WorkspaceField({
@@ -166,6 +170,7 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
icon: 'IconFileUpload',
})
@WorkspaceIsSystem()
@WorkspaceIsNullable()
avatarUrl: string;
@WorkspaceField({
@@ -1,8 +1,8 @@
import { msg } from '@lingui/core/macro';
import {
ActorMetadata,
FieldMetadataType,
RelationOnDeleteAction,
ActorMetadata,
type RichTextV2Metadata,
} from 'twenty-shared/types';
import { STANDARD_OBJECT_IDS } from 'twenty-shared/metadata';
@@ -74,6 +74,7 @@ export class TaskWorkspaceEntity extends BaseWorkspaceEntity {
description: msg`Task title`,
icon: 'IconNotes',
})
@WorkspaceIsNullable()
title: string;
@WorkspaceField({
@@ -6,8 +6,6 @@ exports[`Create input validation - TEXT Gql create input - failure TEXT - should
exports[`Create input validation - TEXT Gql create input - failure TEXT - should fail with : {"textField":1} 1`] = `"String cannot represent a non string value: 1"`;
exports[`Create input validation - TEXT Gql create input - failure TEXT - should fail with : {"textField":null} 1`] = `"null value in column "textField" of relation "_apiInputValidationTestObject" violates not-null constraint"`;
exports[`Create input validation - TEXT Gql create input - failure TEXT - should fail with : {"textField":true} 1`] = `"String cannot represent a non string value: true"`;
exports[`Create input validation - TEXT Rest create input - failure TEXT - should fail with : {"textField":[]} 1`] = `"["Invalid string value [] for text field \\"textField\\""]"`;
@@ -16,6 +14,4 @@ exports[`Create input validation - TEXT Rest create input - failure TEXT - shoul
exports[`Create input validation - TEXT Rest create input - failure TEXT - should fail with : {"textField":1} 1`] = `"["Invalid string value 1 for text field \\"textField\\""]"`;
exports[`Create input validation - TEXT Rest create input - failure TEXT - should fail with : {"textField":null} 1`] = `"["null value in column \\"textField\\" of relation \\"_apiInputValidationTestObject\\" violates not-null constraint"]"`;
exports[`Create input validation - TEXT Rest create input - failure TEXT - should fail with : {"textField":true} 1`] = `"["Invalid string value true for text field \\"textField\\""]"`;
@@ -7,11 +7,6 @@ export const failingCreateInputByFieldMetadataType: {
}[];
} = {
[FieldMetadataType.TEXT]: [
{
input: {
textField: null,
},
},
{
input: {
textField: {},
@@ -134,7 +134,10 @@ export const successfulCreateInputByFieldMetadataType: {
rawJsonField: {},
},
validateInput: (record: Record<string, any>) => {
return Object.keys(record.rawJsonField).length === 0;
return (
typeof record.rawJsonField === 'object' &&
Object.keys(record.rawJsonField).length === 0
);
},
},
{
@@ -142,7 +145,10 @@ export const successfulCreateInputByFieldMetadataType: {
rawJsonField: null,
},
validateInput: (record: Record<string, any>) => {
return record.rawJsonField === null;
return (
typeof record.rawJsonField === 'object' &&
Object.keys(record.rawJsonField).length === 0
);
},
},
{
@@ -182,7 +188,9 @@ export const successfulCreateInputByFieldMetadataType: {
arrayField: [],
},
validateInput: (record: Record<string, any>) => {
return record.arrayField.length === 0;
return (
Array.isArray(record.arrayField) && record.arrayField.length === 0
);
},
},
{
@@ -190,7 +198,9 @@ export const successfulCreateInputByFieldMetadataType: {
arrayField: null,
},
validateInput: (record: Record<string, any>) => {
return record.arrayField === null;
return (
Array.isArray(record.arrayField) && record.arrayField.length === 0
);
},
},
],
@@ -229,7 +239,10 @@ export const successfulCreateInputByFieldMetadataType: {
multiSelectField: [],
},
validateInput: (record: Record<string, any>) => {
return record.multiSelectField.length === 0;
return (
Array.isArray(record.multiSelectField) &&
record.multiSelectField.length === 0
);
},
},
{
@@ -237,7 +250,10 @@ export const successfulCreateInputByFieldMetadataType: {
multiSelectField: null,
},
validateInput: (record: Record<string, any>) => {
return record.multiSelectField === null;
return (
Array.isArray(record.multiSelectField) &&
record.multiSelectField.length === 0
);
},
},
],
@@ -4,7 +4,7 @@ import {
TEST_UUID_FIELD_VALUE,
} from 'test/integration/graphql/suites/inputs-validation/utils/setup-test-objects-with-all-field-types.util';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { isDefined, isEmptyObject } from 'twenty-shared/utils';
export const successfulFilterInputByFieldMetadataType: {
[K in FieldMetadataTypesToTestForFilterInputValidation]: {
@@ -803,7 +803,10 @@ export const successfulFilterInputByFieldMetadataType: {
gqlFilterInput: { multiSelectField: { is: 'NULL' } },
restFilterInput: 'multiSelectField[is]:NULL',
validateFilter: (record: Record<string, any>) => {
return record.multiSelectField === null;
return (
Array.isArray(record.multiSelectField) &&
record.multiSelectField.length === 0
);
},
},
{
@@ -892,7 +895,7 @@ export const successfulFilterInputByFieldMetadataType: {
gqlFilterInput: { rawJsonField: { is: 'NULL' } },
restFilterInput: 'rawJsonField[is]:NULL',
validateFilter: (record: Record<string, any>) => {
return record.rawJsonField === null;
return isEmptyObject(record.rawJsonField);
},
},
{
@@ -923,7 +926,9 @@ export const successfulFilterInputByFieldMetadataType: {
gqlFilterInput: { arrayField: { is: 'NULL' } },
restFilterInput: 'arrayField[is]:NULL',
validateFilter: (record: Record<string, any>) => {
return record.arrayField === null;
return (
Array.isArray(record.arrayField) && record.arrayField.length === 0
);
},
},
//TODO - null and empty array should be equivalent
@@ -55,7 +55,6 @@ describe(`Filter input validation - ${FIELD_METADATA_TYPE}`, () => {
);
});
// TODO : Refacto-common - Uncomment this
describe('Rest filter input - failure', () => {
it.each(
failingTestCases.map((testCase) => ({
@@ -40,7 +40,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<CreatePhoneFieldMetadataTestCase
primaryPhoneNumber: '123456789',
primaryPhoneCallingCode: '+33',
primaryPhoneCountryCode: 'FR',
additionalPhones: null,
additionalPhones: [],
},
},
},
@@ -57,7 +57,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<CreatePhoneFieldMetadataTestCase
primaryPhoneNumber: '123456789',
primaryPhoneCallingCode: '+33',
primaryPhoneCountryCode: 'FR',
additionalPhones: null,
additionalPhones: [],
},
},
},
@@ -75,7 +75,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<CreatePhoneFieldMetadataTestCase
primaryPhoneNumber: '123456789',
primaryPhoneCountryCode: 'US',
primaryPhoneCallingCode: '+1',
additionalPhones: null,
additionalPhones: [],
},
},
},
@@ -88,7 +88,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<CreatePhoneFieldMetadataTestCase
primaryPhoneNumber: '123456789',
primaryPhoneCountryCode: '' as CountryCode,
primaryPhoneCallingCode: '+1',
additionalPhones: null,
additionalPhones: [],
},
},
},
@@ -101,7 +101,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<CreatePhoneFieldMetadataTestCase
primaryPhoneNumber: '123456789',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '+33',
additionalPhones: null,
additionalPhones: [],
},
},
},
@@ -113,7 +113,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<CreatePhoneFieldMetadataTestCase
primaryPhoneNumber: '',
primaryPhoneCountryCode: '' as CountryCode,
primaryPhoneCallingCode: '',
additionalPhones: null,
additionalPhones: [],
},
},
},
@@ -130,7 +130,7 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<CreatePhoneFieldMetadataTestCase
primaryPhoneNumber: '',
primaryPhoneCountryCode: '' as CountryCode,
primaryPhoneCallingCode: '',
additionalPhones: null,
additionalPhones: [],
},
},
},
@@ -130,6 +130,7 @@ export { lowercaseUrlOriginAndRemoveTrailingSlash } from './url/lowercaseUrlOrig
export { uuidToBase36 } from './uuidToBase36';
export { assertIsDefinedOrThrow } from './validation/assertIsDefinedOrThrow';
export { isDefined } from './validation/isDefined';
export { isEmptyObject } from './validation/isEmptyObject';
export { isLabelIdentifierFieldMetadataTypes } from './validation/isLabelIdentifierFieldMetadataTypes';
export { isValidLocale } from './validation/isValidLocale';
export { isValidUuid } from './validation/isValidUuid';
@@ -1,4 +1,4 @@
import { isEmptyObject } from '../isEmptyObject';
import { isEmptyObject } from '@/utils/validation/isEmptyObject';
describe('isEmptyObject', () => {
it('should return true for empty object', () => {