Add unique indexes and indexes for composite types (#7162)

Add support for indexes on composite fields and unicity constraint on
indexes

This pull request includes several changes across multiple files to
improve error handling, enforce unique constraints, and update database
migrations. The most important changes include updating error messages
for snack bars, adding a new command to enforce unique constraints, and
updating database migrations to include new fields and constraints.

### Error Handling Improvements:
*
[`packages/twenty-front/src/modules/error-handler/components/PromiseRejectionEffect.tsx`](diffhunk://#diff-e7dc05ced8e4730430f5c7fcd0c75b3aa723da438c26e0bef8130b614427dd9aL23-R23):
Updated error messages in `enqueueSnackBar` to use `error.message`
directly.
*
[`packages/twenty-front/src/modules/object-metadata/hooks/useFindManyObjectMetadataItems.ts`](diffhunk://#diff-74c126d6bc7a5ed6b63be994d298df6669058034bfbc367b11045f9f31a3abe6L44-R46):
Simplified error messages in `enqueueSnackBar`.
*
[`packages/twenty-front/src/modules/object-record/hooks/useFindDuplicateRecords.ts`](diffhunk://#diff-af23a1d99639a66c251f87473e63e2b7bceaa4ee4f70fedfa0fcffe5c7d79181L56-R58):
Simplified error messages in `enqueueSnackBar`.
*
[`packages/twenty-front/src/modules/object-record/hooks/useHandleFindManyRecordsError.ts`](diffhunk://#diff-da04296cbe280202a1eaf6b1244a30490d4f400411bee139651172c59719088eL22-R24):
Simplified error messages in `enqueueSnackBar`.

### New Command for Unique Constraints:
*
[`packages/twenty-server/src/database/commands/upgrade-version/0-31/0-31-enforce-unique-constraints.command.ts`](diffhunk://#diff-8337096c8c80dd2619a5ba691ae5145101f8ae0368a75192a050047e8c6ab7cbR1-R159):
Added a new command to enforce unique constraints on company domain
names and person emails.
*
[`packages/twenty-server/src/database/commands/upgrade-version/0-31/0-31-upgrade-version.command.ts`](diffhunk://#diff-20215e9981a53c7566e9cbff96715685125878f5bcb84fe461a7440f2e68f6fcR13-R14):
Integrated the new `EnforceUniqueConstraintsCommand` into the upgrade
process.
[[1]](diffhunk://#diff-20215e9981a53c7566e9cbff96715685125878f5bcb84fe461a7440f2e68f6fcR13-R14)
[[2]](diffhunk://#diff-20215e9981a53c7566e9cbff96715685125878f5bcb84fe461a7440f2e68f6fcR31)
[[3]](diffhunk://#diff-20215e9981a53c7566e9cbff96715685125878f5bcb84fe461a7440f2e68f6fcR64-R68)
*
[`packages/twenty-server/src/database/commands/upgrade-version/0-31/0-31-upgrade-version.module.ts`](diffhunk://#diff-da52814efc674c25ed55645f8ee2561013641a407f88423e705dd6c77b405527R7):
Registered the new `EnforceUniqueConstraintsCommand` in the module.
[[1]](diffhunk://#diff-da52814efc674c25ed55645f8ee2561013641a407f88423e705dd6c77b405527R7)
[[2]](diffhunk://#diff-da52814efc674c25ed55645f8ee2561013641a407f88423e705dd6c77b405527R24)

### Database Migrations:
*
[`packages/twenty-server/src/database/typeorm/metadata/migrations/1726757368824-migrationDebt.ts`](diffhunk://#diff-c450aeae7bc0ef4416a0ade2dc613ca3f688629f35d2a32f90a09c3f494febdcR1-R53):
Added a migration to update the `relationMetadata_ondeleteaction_enum`
and set default values.
*
[`packages/twenty-server/src/database/typeorm/metadata/migrations/1726757368825-addIsUniqueToIndexMetadata.ts`](diffhunk://#diff-8f1e14bd7f6835ec2c3bb39bcc51e3c318a3008d576a981e682f4c985e746fbfR1-R19):
Added a migration to include the `isUnique` field in `indexMetadata`.
*
[`packages/twenty-server/src/database/typeorm/metadata/migrations/1726762935841-addCompostiveColumnToIndexFieldMetadata.ts`](diffhunk://#diff-7c96b7276c7722d41ff31de23b2de4d6e09adfdc74815356ba63bc96a2669440R1-R19):
Added a migration to include the `compositeColumn` field in
`indexFieldMetadata`.
*
[`packages/twenty-server/src/database/typeorm/metadata/migrations/1726766871572-addWhereToIndexMetadata.ts`](diffhunk://#diff-26651295a975eb50e672dce0e4e274e861f66feb1b68105eee5a04df32796190R1-R14):
Added a migration to include the `indexWhereClause` field in
`indexMetadata`.

### GraphQL Exception Handling:
*
[`packages/twenty-server/src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util.ts`](diffhunk://#diff-58445eb362dc89e31107777d39b592d7842d2ab09a223012ccd055da325270a8R1-R4):
Enhanced exception handling for `QueryFailedError` to provide more
specific error messages for unique constraint violations.
[[1]](diffhunk://#diff-58445eb362dc89e31107777d39b592d7842d2ab09a223012ccd055da325270a8R1-R4)
[[2]](diffhunk://#diff-58445eb362dc89e31107777d39b592d7842d2ab09a223012ccd055da325270a8R23-R59)
*
[`packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts`](diffhunk://#diff-233d58ab2333586dd45e46e33d4f07e04a4b8adde4a11a48e25d86985e5a7943L58-R58):
Updated the `workspaceQueryRunnerGraphqlApiExceptionHandler` call to
include context.
*
[`packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts`](diffhunk://#diff-68b803f0762c407f5d2d1f5f8d389655a60654a2dd2394a81318655dcd44dc43L58-R58):
Updated the `workspaceQueryRunnerGraphqlApiExceptionHandler` call to
include context.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Félix Malfait
2024-10-13 10:21:03 +02:00
committed by GitHub
parent d1d4af0c63
commit b792d2a4d3
137 changed files with 22351 additions and 17974 deletions

View File

@ -1,4 +1,4 @@
import { ObjectLiteral, WhereExpressionBuilder } from 'typeorm';
import { WhereExpressionBuilder } from 'typeorm';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
@ -6,17 +6,13 @@ import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { computeWhereConditionParts } from 'src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { FieldMetadataMap } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util';
import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory';
import { capitalize } from 'src/utils/capitalize';
type WhereConditionParts = {
sql: string;
params: ObjectLiteral;
};
export class GraphqlQueryFilterFieldParser {
private fieldMetadataMap: FieldMetadataMap;
@ -57,7 +53,7 @@ export class GraphqlQueryFilterFieldParser {
}
}
const { sql, params } = this.computeWhereConditionParts(
const { sql, params } = computeWhereConditionParts(
operator,
objectNameSingular,
key,
@ -71,83 +67,6 @@ export class GraphqlQueryFilterFieldParser {
}
}
private computeWhereConditionParts(
operator: string,
objectNameSingular: string,
key: string,
value: any,
): WhereConditionParts {
const uuid = Math.random().toString(36).slice(2, 7);
switch (operator) {
case 'eq':
return {
sql: `"${objectNameSingular}"."${key}" = :${key}${uuid}`,
params: { [`${key}${uuid}`]: value },
};
case 'neq':
return {
sql: `"${objectNameSingular}"."${key}" != :${key}${uuid}`,
params: { [`${key}${uuid}`]: value },
};
case 'gt':
return {
sql: `"${objectNameSingular}"."${key}" > :${key}${uuid}`,
params: { [`${key}${uuid}`]: value },
};
case 'gte':
return {
sql: `"${objectNameSingular}"."${key}" >= :${key}${uuid}`,
params: { [`${key}${uuid}`]: value },
};
case 'lt':
return {
sql: `"${objectNameSingular}".${key} < :${key}${uuid}`,
params: { [`${key}${uuid}`]: value },
};
case 'lte':
return {
sql: `"${objectNameSingular}"."${key}" <= :${key}${uuid}`,
params: { [`${key}${uuid}`]: value },
};
case 'in':
return {
sql: `"${objectNameSingular}"."${key}" IN (:...${key}${uuid})`,
params: { [`${key}${uuid}`]: value },
};
case 'is':
return {
sql: `"${objectNameSingular}"."${key}" IS ${value === 'NULL' ? 'NULL' : 'NOT NULL'}`,
params: {},
};
case 'like':
return {
sql: `"${objectNameSingular}"."${key}" LIKE :${key}${uuid}`,
params: { [`${key}${uuid}`]: `${value}` },
};
case 'ilike':
return {
sql: `"${objectNameSingular}"."${key}" ILIKE :${key}${uuid}`,
params: { [`${key}${uuid}`]: `${value}` },
};
case 'startsWith':
return {
sql: `"${objectNameSingular}"."${key}" LIKE :${key}${uuid}`,
params: { [`${key}${uuid}`]: `${value}` },
};
case 'endsWith':
return {
sql: `"${objectNameSingular}"."${key}" LIKE :${key}${uuid}`,
params: { [`${key}${uuid}`]: `${value}` },
};
default:
throw new GraphqlQueryRunnerException(
`Operator "${operator}" is not supported`,
GraphqlQueryRunnerExceptionCode.UNSUPPORTED_OPERATOR,
);
}
}
private parseCompositeFieldForFilter(
queryBuilder: WhereExpressionBuilder,
fieldMetadata: FieldMetadataInterface,
@ -182,7 +101,7 @@ export class GraphqlQueryFilterFieldParser {
subFieldFilter as Record<string, any>,
);
const { sql, params } = this.computeWhereConditionParts(
const { sql, params } = computeWhereConditionParts(
operator,
objectNameSingular,
fullFieldName,

View File

@ -107,6 +107,7 @@ export class GraphqlQueryCreateManyResolverService
options: WorkspaceQueryRunnerOptions,
): Promise<void> {
assertMutationNotOnRemoteObject(options.objectMetadataItem);
args.data.forEach((record) => {
if (record?.id) {
assertIsValidUuid(record.id);

View File

@ -0,0 +1,88 @@
import { ObjectLiteral } from 'typeorm';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
type WhereConditionParts = {
sql: string;
params: ObjectLiteral;
};
export const computeWhereConditionParts = (
operator: string,
objectNameSingular: string,
key: string,
value: any,
): WhereConditionParts => {
const uuid = Math.random().toString(36).slice(2, 7);
switch (operator) {
case 'eq':
return {
sql: `"${objectNameSingular}"."${key}" = :${key}${uuid}`,
params: { [`${key}${uuid}`]: value },
};
case 'neq':
return {
sql: `"${objectNameSingular}"."${key}" != :${key}${uuid}`,
params: { [`${key}${uuid}`]: value },
};
case 'gt':
return {
sql: `"${objectNameSingular}"."${key}" > :${key}${uuid}`,
params: { [`${key}${uuid}`]: value },
};
case 'gte':
return {
sql: `"${objectNameSingular}"."${key}" >= :${key}${uuid}`,
params: { [`${key}${uuid}`]: value },
};
case 'lt':
return {
sql: `"${objectNameSingular}"."${key}" < :${key}${uuid}`,
params: { [`${key}${uuid}`]: value },
};
case 'lte':
return {
sql: `"${objectNameSingular}"."${key}" <= :${key}${uuid}`,
params: { [`${key}${uuid}`]: value },
};
case 'in':
return {
sql: `"${objectNameSingular}"."${key}" IN (:...${key}${uuid})`,
params: { [`${key}${uuid}`]: value },
};
case 'is':
return {
sql: `"${objectNameSingular}"."${key}" IS ${value === 'NULL' ? 'NULL' : 'NOT NULL'}`,
params: {},
};
case 'like':
return {
sql: `"${objectNameSingular}"."${key}" LIKE :${key}${uuid}`,
params: { [`${key}${uuid}`]: `${value}` },
};
case 'ilike':
return {
sql: `"${objectNameSingular}"."${key}" ILIKE :${key}${uuid}`,
params: { [`${key}${uuid}`]: `${value}` },
};
case 'startsWith':
return {
sql: `"${objectNameSingular}"."${key}" LIKE :${key}${uuid}`,
params: { [`${key}${uuid}`]: `${value}` },
};
case 'endsWith':
return {
sql: `"${objectNameSingular}"."${key}" LIKE :${key}${uuid}`,
params: { [`${key}${uuid}`]: `${value}` },
};
default:
throw new GraphqlQueryRunnerException(
`Operator "${operator}" is not supported`,
GraphqlQueryRunnerExceptionCode.UNSUPPORTED_OPERATOR,
);
}
};

View File

@ -1,3 +1,7 @@
import { QueryFailedError } from 'typeorm';
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
@ -16,7 +20,51 @@ import {
export const workspaceQueryRunnerGraphqlApiExceptionHandler = (
error: Error,
context: WorkspaceSchemaBuilderContext,
) => {
if (error instanceof QueryFailedError) {
if (
error.message.includes('duplicate key value violates unique constraint')
) {
const indexNameMatch = error.message.match(/"([^"]+)"/);
if (indexNameMatch) {
const indexName = indexNameMatch[1];
const deletedAtFieldMetadata = context.objectMetadataItem.fields.find(
(field) => field.name === 'deletedAt',
);
const affectedColumns = context.objectMetadataItem.indexMetadatas
.find((index) => index.name === indexName)
?.indexFieldMetadatas?.filter(
(field) => field.fieldMetadataId !== deletedAtFieldMetadata?.id,
)
.map((indexField) => {
const fieldMetadata = context.objectMetadataItem.fields.find(
(objectField) => indexField.fieldMetadataId === objectField.id,
);
return fieldMetadata?.label;
});
const columnNames = affectedColumns?.join(', ');
if (affectedColumns?.length === 1) {
throw new UserInputError(
`Duplicate ${columnNames}. Please set a unique one.`,
);
}
throw new UserInputError(
`A duplicate entry was detected. The combination of ${columnNames} must be unique.`,
);
}
}
throw error;
}
if (error instanceof WorkspaceQueryRunnerException) {
switch (error.code) {
case WorkspaceQueryRunnerExceptionCode.DATA_NOT_FOUND:

View File

@ -40,7 +40,7 @@ export class CreateManyResolverFactory
return await this.graphqlQueryRunnerService.createMany(args, options);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
workspaceQueryRunnerGraphqlApiExceptionHandler(error, context);
}
};
}

View File

@ -40,7 +40,7 @@ export class CreateOneResolverFactory
return await this.graphqlQueryRunnerService.createOne(args, options);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
workspaceQueryRunnerGraphqlApiExceptionHandler(error, internalContext);
}
};
}

View File

@ -40,7 +40,7 @@ export class DeleteManyResolverFactory
return await this.graphqlQueryRunnerService.deleteMany(args, options);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
workspaceQueryRunnerGraphqlApiExceptionHandler(error, internalContext);
}
};
}

View File

@ -40,7 +40,7 @@ export class DeleteOneResolverFactory
return await this.graphqlQueryRunnerService.deleteOne(args, options);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
workspaceQueryRunnerGraphqlApiExceptionHandler(error, internalContext);
}
};
}

View File

@ -40,7 +40,7 @@ export class DestroyManyResolverFactory
return await this.graphqlQueryRunnerService.destroyMany(args, options);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
workspaceQueryRunnerGraphqlApiExceptionHandler(error, internalContext);
}
};
}

View File

@ -40,7 +40,7 @@ export class DestroyOneResolverFactory
return await this.graphQLQueryRunnerService.destroyOne(args, options);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
workspaceQueryRunnerGraphqlApiExceptionHandler(error, internalContext);
}
};
}

View File

@ -43,7 +43,7 @@ export class FindDuplicatesResolverFactory
options,
);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
workspaceQueryRunnerGraphqlApiExceptionHandler(error, internalContext);
}
};
}

View File

@ -40,7 +40,7 @@ export class FindManyResolverFactory
return await this.graphqlQueryRunnerService.findMany(args, options);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
workspaceQueryRunnerGraphqlApiExceptionHandler(error, internalContext);
}
};
}

View File

@ -40,7 +40,7 @@ export class FindOneResolverFactory
return await this.graphqlQueryRunnerService.findOne(args, options);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
workspaceQueryRunnerGraphqlApiExceptionHandler(error, internalContext);
}
};
}

View File

@ -40,7 +40,7 @@ export class RestoreManyResolverFactory
return await this.graphqlQueryRunnerService.restoreMany(args, options);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
workspaceQueryRunnerGraphqlApiExceptionHandler(error, internalContext);
}
};
}

View File

@ -38,7 +38,7 @@ export class SearchResolverFactory
return await this.graphqlQueryRunnerService.search(args, options);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
workspaceQueryRunnerGraphqlApiExceptionHandler(error, internalContext);
}
};
}

View File

@ -40,7 +40,7 @@ export class UpdateManyResolverFactory
return await this.graphqlQueryRunnerService.updateMany(args, options);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
workspaceQueryRunnerGraphqlApiExceptionHandler(error, internalContext);
}
};
}

View File

@ -40,7 +40,7 @@ export class UpdateOneResolverFactory
return await this.graphqlQueryRunnerService.updateOne(args, options);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
workspaceQueryRunnerGraphqlApiExceptionHandler(error, internalContext);
}
};
}

View File

@ -5,12 +5,12 @@ import { GraphQLOutputType } from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
import { PageInfoType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/object';
import {
TypeMapperService,
TypeOptions,
} from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service';
import { TypeDefinitionsStorage } from 'src/engine/api/graphql/workspace-schema-builder/storages/type-definitions.storage';
import { PageInfoType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/object';
import { ConnectionTypeDefinitionKind } from './connection-type-definition.factory';
import { ObjectTypeDefinitionKind } from './object-type-definition.factory';
@ -27,7 +27,7 @@ export class ConnectionTypeFactory {
public create(
objectMetadata: ObjectMetadataInterface,
kind: ConnectionTypeDefinitionKind,
buildOtions: WorkspaceBuildSchemaOptions,
buildOptions: WorkspaceBuildSchemaOptions,
typeOptions: TypeOptions,
): GraphQLOutputType {
if (kind === ConnectionTypeDefinitionKind.PageInfo) {
@ -44,7 +44,7 @@ export class ConnectionTypeFactory {
`Edge type for ${objectMetadata.nameSingular} was not found. Please, check if you have defined it.`,
{
objectMetadata,
buildOtions,
buildOptions,
},
);

View File

@ -5,15 +5,15 @@ import { GraphQLOutputType } from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
import { CursorScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import {
TypeMapperService,
TypeOptions,
} from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service';
import { TypeDefinitionsStorage } from 'src/engine/api/graphql/workspace-schema-builder/storages/type-definitions.storage';
import { CursorScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ObjectTypeDefinitionKind } from './object-type-definition.factory';
import { EdgeTypeDefinitionKind } from './edge-type-definition.factory';
import { ObjectTypeDefinitionKind } from './object-type-definition.factory';
@Injectable()
export class EdgeTypeFactory {
@ -27,7 +27,7 @@ export class EdgeTypeFactory {
public create(
objectMetadata: ObjectMetadataInterface,
kind: EdgeTypeDefinitionKind,
buildOtions: WorkspaceBuildSchemaOptions,
buildOptions: WorkspaceBuildSchemaOptions,
typeOptions: TypeOptions,
): GraphQLOutputType {
if (kind === EdgeTypeDefinitionKind.Cursor) {
@ -44,7 +44,7 @@ export class EdgeTypeFactory {
`Node type for ${objectMetadata.nameSingular} was not found. Please, check if you have defined it.`,
{
objectMetadata,
buildOtions,
buildOptions,
},
);

View File

@ -26,7 +26,7 @@ export class OutputTypeFactory {
target: string,
type: FieldMetadataType,
kind: ObjectTypeDefinitionKind,
buildOtions: WorkspaceBuildSchemaOptions,
buildOptions: WorkspaceBuildSchemaOptions,
typeOptions: TypeOptions,
): GraphQLOutputType {
let gqlType: GraphQLOutputType | undefined =
@ -40,8 +40,9 @@ export class OutputTypeFactory {
if (!gqlType) {
this.logger.error(`Could not find a GraphQL type for ${target}`, {
kind,
type,
buildOtions,
buildOptions,
typeOptions,
});

View File

@ -79,6 +79,7 @@ export class TypeMapperService {
StringArrayScalarType as unknown as GraphQLScalarType,
],
[FieldMetadataType.RICH_TEXT, GraphQLString],
[FieldMetadataType.TS_VECTOR, GraphQLString],
]);
return typeScalarMapping.get(fieldMetadataType);
@ -114,6 +115,7 @@ export class TypeMapperService {
[FieldMetadataType.RAW_JSON, RawJsonFilterType],
[FieldMetadataType.RICH_TEXT, StringFilterType],
[FieldMetadataType.ARRAY, ArrayFilterType],
[FieldMetadataType.TS_VECTOR, StringFilterType], // TODO: Add TSVectorFilterType
]);
return typeFilterMapping.get(fieldMetadataType);
@ -137,6 +139,7 @@ export class TypeMapperService {
[FieldMetadataType.RAW_JSON, OrderByDirectionType],
[FieldMetadataType.RICH_TEXT, OrderByDirectionType],
[FieldMetadataType.ARRAY, OrderByDirectionType],
[FieldMetadataType.TS_VECTOR, OrderByDirectionType], // TODO: Add TSVectorOrderByType
]);
return typeOrderByMapping.get(fieldMetadataType);

View File

@ -46,10 +46,7 @@ export const generateFields = <
const fields = {};
for (const fieldMetadata of objectMetadata.fields) {
if (
isRelationFieldMetadataType(fieldMetadata.type) ||
fieldMetadata.type === FieldMetadataType.TS_VECTOR
) {
if (isRelationFieldMetadataType(fieldMetadata.type)) {
continue;
}

View File

@ -76,6 +76,7 @@ export class WorkspaceSchemaFactory {
(objectMetadataItem) => ({
...objectMetadataItem,
fields: Object.values(objectMetadataItem.fields),
indexes: objectMetadataItem.indexMetadatas,
}),
);

View File

@ -31,9 +31,6 @@ describe('mapFieldMetadataToGraphqlQuery', () => {
});
describe('should handle all field metadata types', () => {
Object.values(FieldMetadataType).forEach((fieldMetadataType) => {
if (fieldMetadataType === FieldMetadataType.TS_VECTOR) {
return;
}
it(`with field type ${fieldMetadataType}`, () => {
const field = {
type: fieldMetadataType,

View File

@ -30,6 +30,7 @@ export const mapFieldMetadataToGraphqlQuery = (
FieldMetadataType.RAW_JSON,
FieldMetadataType.RICH_TEXT,
FieldMetadataType.ARRAY,
FieldMetadataType.TS_VECTOR,
].includes(fieldType);
if (fieldIsSimpleValue) {

View File

@ -13,6 +13,7 @@ const mockObjectMetadata: ObjectMetadataInterface = {
fromRelations: [],
toRelations: [],
fields: [],
indexMetadatas: [],
isSystem: false,
isCustom: false,
isActive: true,

View File

@ -14,4 +14,5 @@ export enum FeatureFlagKey {
IsWorkspaceMigratedForSearch = 'IS_WORKSPACE_MIGRATED_FOR_SEARCH',
IsGmailSendEmailScopeEnabled = 'IS_GMAIL_SEND_EMAIL_SCOPE_ENABLED',
IsAnalyticsV2Enabled = 'IS_ANALYTICS_V2_ENABLED',
IsUniqueIndexesEnabled = 'IS_UNIQUE_INDEXES_ENABLED',
}

View File

@ -10,6 +10,7 @@ export const emailsCompositeType: CompositeType = {
type: FieldMetadataType.TEXT,
hidden: false,
isRequired: false,
isIncludedInUniqueConstraint: true,
},
{
name: 'additionalEmails',

View File

@ -10,12 +10,14 @@ export const fullNameCompositeType: CompositeType = {
type: FieldMetadataType.TEXT,
hidden: false,
isRequired: false,
isIncludedInUniqueConstraint: true,
},
{
name: 'lastName',
type: FieldMetadataType.TEXT,
hidden: false,
isRequired: false,
isIncludedInUniqueConstraint: true,
},
],
};

View File

@ -16,6 +16,7 @@ export const linksCompositeType: CompositeType = {
type: FieldMetadataType.TEXT,
hidden: false,
isRequired: false,
isIncludedInUniqueConstraint: true,
},
{
name: 'secondaryLinks',

View File

@ -10,6 +10,7 @@ export const phonesCompositeType: CompositeType = {
type: FieldMetadataType.TEXT,
hidden: false,
isRequired: false,
isIncludedInUniqueConstraint: true,
},
{
name: 'primaryPhoneCountryCode',

View File

@ -118,6 +118,11 @@ export class FieldMetadataDTO<
@Field({ nullable: true })
isNullable?: boolean;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
isUnique?: boolean;
@Validate(IsFieldMetadataDefaultValue)
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })

View File

@ -108,6 +108,9 @@ export class FieldMetadataEntity<
@Column({ nullable: true, default: true })
isNullable: boolean;
@Column({ nullable: true, default: false })
isUnique: boolean;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@ -126,7 +129,7 @@ export class FieldMetadataEntity<
@OneToMany(
() => IndexFieldMetadataEntity,
(indexFieldMetadata: IndexFieldMetadataEntity) =>
indexFieldMetadata.fieldMetadata,
indexFieldMetadata.indexMetadata,
{
cascade: true,
},

View File

@ -10,6 +10,7 @@ export interface CompositeProperty<
type: Type;
hidden: 'input' | 'output' | true | false;
isRequired: boolean;
isIncludedInUniqueConstraint?: boolean;
isArray?: boolean;
options?: FieldMetadataOptions<Type>;
}

View File

@ -19,6 +19,7 @@ export interface FieldMetadataInterface<
workspaceId?: string;
description?: string;
isNullable?: boolean;
isUnique?: boolean;
fromRelationMetadata?: RelationMetadataEntity;
toRelationMetadata?: RelationMetadataEntity;
isCustom?: boolean;

View File

@ -1,3 +1,5 @@
import { IndexMetadataInterface } from 'src/engine/metadata-modules/index-metadata/interfaces/index-metadata.interface';
import { FieldMetadataInterface } from './field-metadata.interface';
import { RelationMetadataInterface } from './relation-metadata.interface';
@ -13,6 +15,7 @@ export interface ObjectMetadataInterface {
fromRelations: RelationMetadataInterface[];
toRelations: RelationMetadataInterface[];
fields: FieldMetadataInterface[];
indexMetadatas: IndexMetadataInterface[];
isSystem: boolean;
isCustom: boolean;
isActive: boolean;

View File

@ -0,0 +1,62 @@
import { Field, HideField, ObjectType } from '@nestjs/graphql';
import {
Authorize,
FilterableField,
IDField,
QueryOptions,
Relation,
} from '@ptc-org/nestjs-query-graphql';
import { IsDateString, IsNotEmpty, IsNumber, IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { IndexMetadataDTO } from './index-metadata.dto';
@ObjectType('indexField')
@Authorize({
authorize: (context: any) => ({
workspaceId: { eq: context?.req?.workspace?.id },
}),
})
@QueryOptions({
defaultResultSize: 10,
disableSort: true,
maxResultsSize: 1000,
})
@Relation('indexMetadata', () => IndexMetadataDTO, {
nullable: true,
})
@Relation('fieldMetadata', () => FieldMetadataDTO, {
nullable: true,
})
export class IndexFieldMetadataDTO {
@IsUUID()
@IsNotEmpty()
@IDField(() => UUIDScalarType)
id: string;
indexMetadataId: string;
@IsUUID()
@IsNotEmpty()
@FilterableField(() => UUIDScalarType)
fieldMetadataId: string;
@IsNumber()
@IsNotEmpty()
@Field()
order: number;
@IsDateString()
@Field()
createdAt: Date;
@IsDateString()
@Field()
updatedAt: Date;
@HideField()
workspaceId: string;
}

View File

@ -0,0 +1,93 @@
import {
Field,
HideField,
ObjectType,
registerEnumType,
} from '@nestjs/graphql';
import {
Authorize,
CursorConnection,
FilterableField,
IDField,
QueryOptions,
} from '@ptc-org/nestjs-query-graphql';
import {
IsBoolean,
IsDateString,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
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 { IndexFieldMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-field-metadata.dto';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
registerEnumType(IndexType, {
name: 'IndexType',
description: 'Type of the index',
});
@ObjectType('index')
@Authorize({
authorize: (context: any) => ({
workspaceId: { eq: context?.req?.workspace?.id },
}),
})
@QueryOptions({
defaultResultSize: 10,
disableSort: true,
maxResultsSize: 1000,
})
@CursorConnection('objectMetadata', () => ObjectMetadataDTO)
@CursorConnection('indexFieldMetadatas', () => IndexFieldMetadataDTO)
export class IndexMetadataDTO {
@IsUUID()
@IsNotEmpty()
@IDField(() => UUIDScalarType)
id: string;
@IsString()
@IsNotEmpty()
@Field()
@IsValidMetadataName()
name: string;
@IsBoolean()
@IsOptional()
@FilterableField({ nullable: true })
isCustom?: boolean;
@IsBoolean()
@IsNotEmpty()
@Field()
isUnique: boolean;
@IsString()
@IsOptional()
@Field({ nullable: true })
indexWhereClause?: string;
@IsEnum(IndexType)
@IsNotEmpty()
@Field(() => IndexType)
indexType: IndexType;
objectMetadataId: string;
@IsDateString()
@Field()
createdAt: Date;
@IsDateString()
@Field()
updatedAt: Date;
@HideField()
workspaceId: string;
}

View File

@ -23,6 +23,12 @@ export class IndexMetadataEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@Column({ nullable: false })
name: string;
@ -32,7 +38,7 @@ export class IndexMetadataEntity {
@Column({ nullable: false, type: 'uuid' })
objectMetadataId: string;
@ManyToOne(() => ObjectMetadataEntity, (object) => object.indexes, {
@ManyToOne(() => ObjectMetadataEntity, (object) => object.indexMetadatas, {
onDelete: 'CASCADE',
})
@JoinColumn()
@ -48,15 +54,15 @@ export class IndexMetadataEntity {
)
indexFieldMetadatas: Relation<IndexFieldMetadataEntity[]>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@Column({ default: false })
isCustom: boolean;
@Column({ nullable: false, default: false })
isUnique: boolean;
@Column({ type: 'text', nullable: true })
indexWhereClause: string | null;
@Column({
type: 'enum',
enum: IndexType,

View File

@ -1,14 +1,50 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SortDirection } from '@ptc-org/nestjs-query-core';
import { NestjsQueryGraphQLModule } from '@ptc-org/nestjs-query-graphql';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto';
import { IndexFieldMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-field-metadata.entity';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { IndexMetadataService } from 'src/engine/metadata-modules/index-metadata/index-metadata.service';
import { ObjectMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/object-metadata/interceptors/object-metadata-graphql-api-exception.interceptor';
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
@Module({
imports: [
TypeOrmModule.forFeature([IndexMetadataEntity], 'metadata'),
WorkspaceMigrationModule,
NestjsQueryGraphQLModule.forFeature({
imports: [
NestjsQueryTypeOrmModule.forFeature(
[IndexMetadataEntity, IndexFieldMetadataEntity],
'metadata',
),
WorkspaceMigrationModule,
],
services: [IndexMetadataService],
resolvers: [
{
EntityClass: IndexMetadataEntity,
DTOClass: IndexMetadataDTO,
read: {
defaultSort: [{ field: 'id', direction: SortDirection.DESC }],
many: {
name: 'indexMetadatas', //TODO: check + singular
},
},
create: {
disabled: true,
},
update: { disabled: true },
delete: { disabled: true },
guards: [WorkspaceAuthGuard],
interceptors: [ObjectMetadataGraphqlApiExceptionInterceptor],
},
],
}),
],
providers: [IndexMetadataService],
exports: [IndexMetadataService],

View File

@ -32,8 +32,10 @@ export class IndexMetadataService {
workspaceId: string,
objectMetadata: ObjectMetadataEntity,
fieldMetadataToIndex: Partial<FieldMetadataEntity>[],
isUnique: boolean,
isCustom: boolean,
indexType?: IndexType,
indexWhereClause?: string,
) {
const tableName = computeObjectTargetTable(objectMetadata);
@ -82,6 +84,8 @@ export class IndexMetadataService {
action: WorkspaceMigrationIndexActionType.CREATE,
columns: columnNames,
name: indexName,
isUnique,
where: indexWhereClause,
type: indexType,
},
],

View File

@ -0,0 +1,11 @@
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { IndexMetadataInterface } from 'src/engine/metadata-modules/index-metadata/interfaces/index-metadata.interface';
export interface IndexFieldMetadataInterface {
id: string;
indexMetadataId: string;
fieldMetadataId: string;
fieldMetadata: FieldMetadataInterface;
indexMetadata: IndexMetadataInterface;
order: number;
}

View File

@ -0,0 +1,7 @@
import { IndexFieldMetadataInterface } from 'src/engine/metadata-modules/index-metadata/interfaces/index-field-metadata.interface';
export interface IndexMetadataInterface {
name: string;
isUnique: boolean;
indexFieldMetadatas: IndexFieldMetadataInterface[];
}

View File

@ -11,6 +11,7 @@ import {
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto';
import { BeforeDeleteOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-delete-one-object.hook';
@ObjectType('object')
@ -26,6 +27,7 @@ import { BeforeDeleteOneObject } from 'src/engine/metadata-modules/object-metada
})
@BeforeDeleteOne(BeforeDeleteOneObject)
@CursorConnection('fields', () => FieldMetadataDTO)
@CursorConnection('indexMetadatas', () => IndexMetadataDTO)
export class ObjectMetadataDTO {
@IDField(() => UUIDScalarType)
id: string;

View File

@ -86,7 +86,7 @@ export class ObjectMetadataEntity implements ObjectMetadataInterface {
@OneToMany(() => IndexMetadataEntity, (index) => index.objectMetadata, {
cascade: true,
})
indexes: Relation<IndexMetadataEntity[]>;
indexMetadatas: Relation<IndexMetadataEntity[]>;
@OneToMany(
() => RelationMetadataEntity,

View File

@ -673,6 +673,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
createdObjectMetadata,
[searchVectorFieldMetadata],
false,
false,
IndexType.GIN,
);
}

View File

@ -154,6 +154,7 @@ export class RelationMetadataService extends TypeOrmQueryService<RelationMetadat
toObjectMetadata,
[foreignKeyFieldMetadata, deletedFieldMetadata],
false,
false,
);
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(

View File

@ -77,6 +77,8 @@ export class WorkspaceMetadataCacheService {
'fields',
'fields.fromRelationMetadata',
'fields.toRelationMetadata',
'indexMetadatas',
'indexMetadatas.indexFieldMetadatas',
],
});

View File

@ -49,6 +49,7 @@ export class BasicColumnActionFactory extends ColumnActionAbstractFactory<BasicF
columnType: fieldMetadataTypeToColumnType(fieldMetadata.type),
isArray: fieldMetadata.type === FieldMetadataType.ARRAY,
isNullable: fieldMetadata.isNullable ?? true,
isUnique: fieldMetadata.isUnique ?? false,
defaultValue: serializedDefaultValue,
},
];
@ -83,6 +84,7 @@ export class BasicColumnActionFactory extends ColumnActionAbstractFactory<BasicF
columnType: fieldMetadataTypeToColumnType(currentFieldMetadata.type),
isArray: currentFieldMetadata.type === FieldMetadataType.ARRAY,
isNullable: currentFieldMetadata.isNullable ?? true,
isUnique: currentFieldMetadata.isUnique ?? false,
defaultValue: serializeDefaultValue(
currentFieldMetadata.defaultValue,
),
@ -92,6 +94,7 @@ export class BasicColumnActionFactory extends ColumnActionAbstractFactory<BasicF
columnType: fieldMetadataTypeToColumnType(alteredFieldMetadata.type),
isArray: alteredFieldMetadata.type === FieldMetadataType.ARRAY,
isNullable: alteredFieldMetadata.isNullable ?? true,
isUnique: alteredFieldMetadata.isUnique ?? false,
defaultValue: serializedDefaultValue,
},
},

View File

@ -69,6 +69,7 @@ export class CompositeColumnActionFactory extends ColumnActionAbstractFactory<Co
columnType: fieldMetadataTypeToColumnType(property.type),
enum: enumOptions,
isNullable: fieldMetadata.isNullable || !property.isRequired,
isUnique: fieldMetadata.isUnique,
defaultValue: serializedDefaultValue,
isArray:
property.type === FieldMetadataType.MULTI_SELECT || property.isArray,
@ -168,6 +169,7 @@ export class CompositeColumnActionFactory extends ColumnActionAbstractFactory<Co
: undefined,
isNullable:
currentFieldMetadata.isNullable || !currentProperty.isRequired,
isUnique: currentFieldMetadata.isUnique ?? false,
defaultValue: serializeDefaultValue(
currentFieldMetadata.defaultValue?.[currentProperty.name],
),
@ -181,6 +183,7 @@ export class CompositeColumnActionFactory extends ColumnActionAbstractFactory<Co
enum: enumOptions,
isNullable:
alteredFieldMetadata.isNullable || !alteredProperty.isRequired,
isUnique: alteredFieldMetadata.isUnique ?? false,
defaultValue: serializedDefaultValue,
isArray:
alteredProperty.type === FieldMetadataType.MULTI_SELECT ||

View File

@ -46,6 +46,7 @@ export class EnumColumnActionFactory extends ColumnActionAbstractFactory<EnumFie
enum: enumOptions,
isArray: fieldMetadata.type === FieldMetadataType.MULTI_SELECT,
isNullable: fieldMetadata.isNullable ?? true,
isUnique: fieldMetadata.isUnique ?? false,
defaultValue: serializedDefaultValue,
},
];
@ -103,6 +104,7 @@ export class EnumColumnActionFactory extends ColumnActionAbstractFactory<EnumFie
: undefined,
isArray: currentFieldMetadata.type === FieldMetadataType.MULTI_SELECT,
isNullable: currentFieldMetadata.isNullable ?? true,
isUnique: currentFieldMetadata.isUnique ?? false,
defaultValue: serializeDefaultValue(
currentFieldMetadata.defaultValue,
),
@ -113,6 +115,7 @@ export class EnumColumnActionFactory extends ColumnActionAbstractFactory<EnumFie
enum: enumOptions,
isArray: alteredFieldMetadata.type === FieldMetadataType.MULTI_SELECT,
isNullable: alteredFieldMetadata.isNullable ?? true,
isUnique: alteredFieldMetadata.isUnique ?? false,
defaultValue: serializedDefaultValue,
},
},

View File

@ -32,6 +32,7 @@ export class TsVectorColumnActionFactory extends ColumnActionAbstractFactory<TsV
columnName: computeColumnName(fieldMetadata),
columnType: fieldMetadataTypeToColumnType(fieldMetadata.type),
isNullable: fieldMetadata.isNullable ?? true,
isUnique: fieldMetadata.isUnique ?? false,
defaultValue: undefined,
generatedType: fieldMetadata.generatedType,
asExpression: fieldMetadata.asExpression,

View File

@ -30,6 +30,7 @@ export interface WorkspaceMigrationColumnDefinition {
enum?: WorkspaceMigrationEnum[];
isArray?: boolean;
isNullable: boolean;
isUnique?: boolean;
defaultValue: any;
generatedType?: 'STORED' | 'VIRTUAL';
asExpression?: string;
@ -39,6 +40,8 @@ export interface WorkspaceMigrationIndexAction {
action: WorkspaceMigrationIndexActionType;
name: string;
columns: string[];
isUnique: boolean;
where?: string | null;
type?: IndexType;
}

View File

@ -12,8 +12,8 @@ import {
} from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceCustomEntity } from 'src/engine/twenty-orm/decorators/workspace-custom-entity.decorator';
import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator';
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
import { WorkspaceIndex } from 'src/engine/twenty-orm/decorators/workspace-index.decorator';
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator';
@ -157,6 +157,6 @@ export class CustomWorkspaceEntity extends BaseWorkspaceEntity {
})
@WorkspaceIsNullable()
@WorkspaceIsSystem()
@WorkspaceIndex({ indexType: IndexType.GIN })
@WorkspaceFieldIndex({ indexType: IndexType.GIN })
[SEARCH_VECTOR_FIELD.name]: any;
}

View File

@ -0,0 +1,44 @@
import { generateDeterministicIndexName } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name';
import { WorkspaceIndexOptions } from 'src/engine/twenty-orm/decorators/workspace-index.decorator';
import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage';
import { getColumnsForIndex } from 'src/engine/twenty-orm/utils/get-default-columns-for-index.util';
import { convertClassNameToObjectMetadataName } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/convert-class-to-object-metadata-name.util';
import { TypedReflect } from 'src/utils/typed-reflect';
export function WorkspaceFieldIndex(
options?: WorkspaceIndexOptions,
): PropertyDecorator {
return (target: any, propertyKey: string | symbol) => {
if (propertyKey === undefined) {
throw new Error('This decorator should be used with a field not a class');
}
const gate = TypedReflect.getMetadata(
'workspace:gate-metadata-args',
target,
propertyKey.toString(),
);
const additionalDefaultColumnsForIndex = getColumnsForIndex(
options?.indexType,
);
const columns = [
propertyKey.toString(),
...additionalDefaultColumnsForIndex,
];
metadataArgsStorage.addIndexes({
name: `IDX_${generateDeterministicIndexName([
convertClassNameToObjectMetadataName(target.constructor.name),
...columns,
])}`,
columns,
target: target.constructor,
gate,
isUnique: options?.isUnique ?? false,
whereClause: options?.indexWhereClause ?? null,
type: options?.indexType,
});
};
}

View File

@ -57,6 +57,12 @@ export function WorkspaceField<T extends FieldMetadataType>(
object,
propertyKey.toString(),
) ?? false;
const isUnique =
TypedReflect.getMetadata(
'workspace:is-unique-metadata-args',
object,
propertyKey.toString(),
) ?? false;
const defaultValue = (options.defaultValue ??
generateDefaultValue(options.type)) as FieldMetadataDefaultValue | null;
@ -77,6 +83,7 @@ export function WorkspaceField<T extends FieldMetadataType>(
isSystem,
gate,
isDeprecated,
isUnique,
isActive: options.isActive,
asExpression: options.asExpression,
generatedType: options.generatedType,

View File

@ -1,83 +1,40 @@
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { generateDeterministicIndexName } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name';
import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage';
import { getColumnsForIndex } from 'src/engine/twenty-orm/utils/get-default-columns-for-index.util';
import { convertClassNameToObjectMetadataName } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/convert-class-to-object-metadata-name.util';
import { isDefined } from 'src/utils/is-defined';
import { TypedReflect } from 'src/utils/typed-reflect';
export type WorkspaceIndexMetadata = {
columns?: string[];
export type WorkspaceIndexOptions = {
isUnique?: boolean;
indexWhereClause?: string;
indexType?: IndexType;
};
export function WorkspaceIndex(
metadata?: WorkspaceIndexMetadata,
): PropertyDecorator;
export function WorkspaceIndex(
metadata: WorkspaceIndexMetadata,
): ClassDecorator;
export function WorkspaceIndex(
metadata?: WorkspaceIndexMetadata,
): PropertyDecorator | ClassDecorator {
return (target: any, propertyKey: string | symbol) => {
if (propertyKey === undefined && metadata === undefined) {
throw new Error('Class level WorkspaceIndex should be used with columns');
}
if (propertyKey !== undefined && metadata?.columns !== undefined) {
throw new Error(
'Property level WorkspaceIndex should not be used with columns',
);
}
columns: string[],
options: WorkspaceIndexOptions,
): ClassDecorator {
if (!Array.isArray(columns) || columns.length === 0) {
throw new Error('Class level WorkspaceIndex should be used with columns');
}
return (target: any) => {
const gate = TypedReflect.getMetadata(
'workspace:gate-metadata-args',
target,
propertyKey.toString(),
);
// TODO: handle composite field metadata types
if (isDefined(metadata?.columns)) {
const columns = metadata.columns;
if (columns.length > 0) {
metadataArgsStorage.addIndexes({
name: `IDX_${generateDeterministicIndexName([
convertClassNameToObjectMetadataName(target.name),
...columns,
])}`,
columns,
target: target,
gate,
...(isDefined(metadata?.indexType)
? { type: metadata.indexType }
: {}),
});
return;
}
}
if (isDefined(propertyKey)) {
const additionalDefaultColumnsForIndex = getColumnsForIndex(
metadata?.indexType,
);
const columns = [
propertyKey.toString(),
...additionalDefaultColumnsForIndex,
];
metadataArgsStorage.addIndexes({
name: `IDX_${generateDeterministicIndexName([
convertClassNameToObjectMetadataName(target.constructor.name),
...columns,
])}`,
columns,
target: target.constructor,
...(isDefined(metadata?.indexType) ? { type: metadata.indexType } : {}),
gate,
});
}
metadataArgsStorage.addIndexes({
name: `IDX_${generateDeterministicIndexName([
convertClassNameToObjectMetadataName(target.name),
...columns,
])}`,
columns,
target: target,
gate,
isUnique: options?.isUnique ?? false,
whereClause: options?.indexWhereClause ?? null,
type: options?.indexType,
});
};
}

View File

@ -0,0 +1,39 @@
import { generateDeterministicIndexName } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name';
import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage';
import { convertClassNameToObjectMetadataName } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/convert-class-to-object-metadata-name.util';
import { TypedReflect } from 'src/utils/typed-reflect';
export function WorkspaceIsUnique(): PropertyDecorator {
return (target: any, propertyKey: string | symbol) => {
if (propertyKey === undefined) {
throw new Error('This decorator should be used with a field not a class');
}
const gate = TypedReflect.getMetadata(
'workspace:gate-metadata-args',
target,
propertyKey.toString(),
);
const columns = [propertyKey.toString()];
metadataArgsStorage.addIndexes({
name: `IDX_UNIQUE_${generateDeterministicIndexName([
convertClassNameToObjectMetadataName(target.constructor.name),
...columns,
])}`,
columns,
target: target.constructor,
gate,
isUnique: true,
whereClause: null,
});
return TypedReflect.defineMetadata(
'workspace:is-unique-metadata-args',
true,
target,
propertyKey.toString(),
);
};
}

View File

@ -1,4 +1,4 @@
import { WorkspaceIndex } from 'src/engine/twenty-orm/decorators/workspace-index.decorator';
import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator';
import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage';
export function WorkspaceJoinColumn(
@ -12,6 +12,6 @@ export function WorkspaceJoinColumn(
});
// Register index for join column
WorkspaceIndex()(object, propertyKey);
WorkspaceFieldIndex()(object, propertyKey);
};
}

View File

@ -75,6 +75,11 @@ export interface WorkspaceFieldMetadataArgs {
*/
readonly isNullable: boolean;
/**
* Is unique field.
*/
readonly isUnique: boolean;
/**
* Field gate.
*/

View File

@ -19,11 +19,21 @@ export interface WorkspaceIndexMetadataArgs {
*/
columns: string[];
/**
* Is index unique.
*/
isUnique: boolean;
/*
* Index type. Defaults to Btree.
*/
type?: IndexType;
/**
* Index where clause.
*/
whereClause: string | null;
/**
* Field gate.
*/

View File

@ -5,9 +5,9 @@ export interface WorkspaceTableStructure {
dataType: string;
columnDefault: string;
isNullable: boolean;
isUnique: boolean;
isPrimaryKey: boolean;
isForeignKey: boolean;
isUnique: boolean;
isArray: boolean;
onUpdateAction: string;
onDeleteAction: string;

View File

@ -1,7 +1,11 @@
import { Injectable } from '@nestjs/common';
import { CompositeType } from 'src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface';
import { WorkspaceMigrationBuilderAction } from 'src/engine/workspace-manager/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { 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';
@ -77,10 +81,8 @@ export class WorkspaceMigrationIndexFactory {
objectMetadata.fields.map((field) => [field.id, field]),
);
const indexes = indexMetadataCollection.map((indexMetadata) => ({
name: indexMetadata.name,
action: WorkspaceMigrationIndexActionType.CREATE,
columns: indexMetadata.indexFieldMetadatas
const indexes = indexMetadataCollection.map((indexMetadata) => {
const columns = indexMetadata.indexFieldMetadatas
.sort((a, b) => a.order - b.order)
.map((indexFieldMetadata) => {
const fieldMetadata =
@ -92,10 +94,35 @@ export class WorkspaceMigrationIndexFactory {
);
}
return fieldMetadata.name;
}),
type: indexMetadata.indexType,
}));
if (!isCompositeFieldMetadataType(fieldMetadata.type)) {
return fieldMetadata.name;
}
const compositeType = compositeTypeDefinitions.get(
fieldMetadata.type,
) as CompositeType;
return compositeType.properties
.filter((property) => property.isIncludedInUniqueConstraint)
.map((property) =>
computeCompositeColumnName(fieldMetadata, property),
);
})
.flat();
const defaultWhereClause = indexMetadata.isUnique
? `${columns.map((column) => `"${column}"`).join(" != '' AND ")} != '' AND "deletedAt" IS NULL`
: null;
return {
name: indexMetadata.name,
action: WorkspaceMigrationIndexActionType.CREATE,
isUnique: indexMetadata.isUnique,
columns,
type: indexMetadata.indexType,
where: indexMetadata.indexWhereClause ?? defaultWhereClause,
};
});
workspaceMigrations.push({
workspaceId: objectMetadata.workspaceId,
@ -134,6 +161,7 @@ export class WorkspaceMigrationIndexFactory {
name: indexMetadata.name,
action: WorkspaceMigrationIndexActionType.DROP,
columns: [],
isUnique: indexMetadata.isUnique,
}));
workspaceMigrations.push({

View File

@ -77,6 +77,7 @@ export class WorkspaceMigrationEnumService {
enumName: newEnumTypeName,
isArray: columnDefinition.isArray,
isNullable: columnDefinition.isNullable,
isUnique: columnDefinition.isUnique,
}),
);

View File

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'class-validator';
import {
QueryRunner,
Table,
@ -9,6 +10,7 @@ import {
TableUnique,
} from 'typeorm';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import {
WorkspaceMigrationColumnAction,
WorkspaceMigrationColumnActionType,
@ -27,7 +29,6 @@ import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/work
import { WorkspaceMigrationEnumService } from 'src/engine/workspace-manager/workspace-migration-runner/services/workspace-migration-enum.service';
import { convertOnDeleteActionToOnDelete } from 'src/engine/workspace-manager/workspace-migration-runner/utils/convert-on-delete-action-to-on-delete.util';
import { tableDefaultColumns } from 'src/engine/workspace-manager/workspace-migration-runner/utils/table-default-column.util';
import { isDefined } from 'src/utils/is-defined';
import { WorkspaceMigrationTypeService } from './services/workspace-migration-type.service';
@ -200,7 +201,7 @@ export class WorkspaceMigrationRunnerService {
for (const index of indexes) {
switch (index.action) {
case WorkspaceMigrationIndexActionType.CREATE:
if (isDefined(index.type)) {
if (isDefined(index.type) && index.type !== IndexType.BTREE) {
const quotedColumns = index.columns.map((column) => `"${column}"`);
await queryRunner.query(`
@ -212,6 +213,8 @@ export class WorkspaceMigrationRunnerService {
new TableIndex({
name: index.name,
columnNames: index.columns,
isUnique: index.isUnique,
where: index.where ?? undefined,
}),
);
}
@ -404,6 +407,7 @@ export class WorkspaceMigrationRunnerService {
enumName: enumName,
isArray: migrationColumn.isArray,
isNullable: migrationColumn.isNullable,
isUnique: migrationColumn.isUnique,
asExpression: migrationColumn.asExpression,
generatedType: migrationColumn.generatedType,
}),
@ -459,6 +463,7 @@ export class WorkspaceMigrationRunnerService {
),
isArray: migrationColumn.currentColumnDefinition.isArray,
isNullable: migrationColumn.currentColumnDefinition.isNullable,
isUnique: migrationColumn.currentColumnDefinition.isUnique,
}),
new TableColumn({
name: migrationColumn.alteredColumnDefinition.columnName,
@ -469,6 +474,7 @@ export class WorkspaceMigrationRunnerService {
),
isArray: migrationColumn.alteredColumnDefinition.isArray,
isNullable: migrationColumn.alteredColumnDefinition.isNullable,
isUnique: migrationColumn.alteredColumnDefinition.isUnique,
}),
);
}

View File

@ -3,8 +3,8 @@ import { Injectable } from '@nestjs/common';
import diff from 'microdiff';
import {
IndexComparatorResult,
ComparatorAction,
IndexComparatorResult,
} from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';

View File

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import diff from 'microdiff';
import omit from 'lodash.omit';
import diff from 'microdiff';
import {
ComparatorAction,
@ -9,8 +9,8 @@ import {
} from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface';
import { ComputedPartialWorkspaceEntity } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-object-metadata.interface';
import { transformMetadataForComparison } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { transformMetadataForComparison } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util';
const objectPropertiesToIgnore = [
'id',
@ -28,7 +28,10 @@ export class WorkspaceObjectComparator {
public compare(
originalObjectMetadata: Omit<ObjectMetadataEntity, 'fields'> | undefined,
standardObjectMetadata: Omit<ComputedPartialWorkspaceEntity, 'fields'>,
standardObjectMetadata: Omit<
ComputedPartialWorkspaceEntity,
'fields' | 'indexMetadatas'
>,
): ObjectComparatorResult {
// If the object doesn't exist in the original metadata, we need to create it
if (!originalObjectMetadata) {

View File

@ -12,6 +12,7 @@ import {
import { WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { RelationMetadataType } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage';
import { getJoinColumn } from 'src/engine/twenty-orm/utils/get-join-column.util';
@ -163,6 +164,7 @@ export class StandardFieldFactory {
settings: workspaceFieldMetadataArgs.settings,
workspaceId: context.workspaceId,
isNullable: workspaceFieldMetadataArgs.isNullable,
isUnique: workspaceFieldMetadataArgs.isUnique,
isCustom: workspaceFieldMetadataArgs.isDeprecated ? true : false,
isSystem: workspaceFieldMetadataArgs.isSystem ?? false,
isActive: workspaceFieldMetadataArgs.isActive ?? true,
@ -218,6 +220,9 @@ export class StandardFieldFactory {
isCustom: false,
isSystem: true,
isNullable: workspaceRelationMetadataArgs.isNullable,
isUnique:
workspaceRelationMetadataArgs.type ===
RelationMetadataType.ONE_TO_ONE,
isActive: workspaceRelationMetadataArgs.isActive ?? true,
});
}
@ -236,6 +241,8 @@ export class StandardFieldFactory {
workspaceEntityMetadataArgs?.isSystem ||
workspaceRelationMetadataArgs.isSystem,
isNullable: true,
isUnique:
workspaceRelationMetadataArgs.type === RelationMetadataType.ONE_TO_ONE,
isActive: workspaceRelationMetadataArgs.isActive ?? true,
});

View File

@ -88,7 +88,9 @@ export class StandardIndexFactory {
objectMetadataId: objectMetadata.id,
name: workspaceIndexMetadataArgs.name,
columns: workspaceIndexMetadataArgs.columns,
isUnique: workspaceIndexMetadataArgs.isUnique,
isCustom: false,
indexWhereClause: workspaceIndexMetadataArgs.whereClause,
indexType: workspaceIndexMetadataArgs.type,
};
@ -130,7 +132,9 @@ export class StandardIndexFactory {
name: `IDX_${generateDeterministicIndexName([computeTableName(customObjectName, true), ...workspaceIndexMetadataArgs.columns])}`,
columns: workspaceIndexMetadataArgs.columns,
isCustom: false,
isUnique: workspaceIndexMetadataArgs.isUnique,
indexType: workspaceIndexMetadataArgs.type,
indexWhereClause: workspaceIndexMetadataArgs.whereClause,
};
return indexMetadata;

View File

@ -14,7 +14,7 @@ export class StandardObjectFactory {
standardObjectMetadataDefinitions: (typeof BaseWorkspaceEntity)[],
context: WorkspaceSyncContext,
workspaceFeatureFlagsMap: FeatureFlagMap,
): Omit<PartialWorkspaceEntity, 'fields'>[] {
): Omit<PartialWorkspaceEntity, 'fields' | 'indexMetadatas'>[] {
return standardObjectMetadataDefinitions
.map((metadata) =>
this.createObjectMetadata(metadata, context, workspaceFeatureFlagsMap),
@ -26,7 +26,7 @@ export class StandardObjectFactory {
target: typeof BaseWorkspaceEntity,
context: WorkspaceSyncContext,
workspaceFeatureFlagsMap: FeatureFlagMap,
): Omit<PartialWorkspaceEntity, 'fields'> | undefined {
): Omit<PartialWorkspaceEntity, 'fields' | 'indexMetadatas'> | undefined {
const workspaceEntityMetadataArgs =
metadataArgsStorage.filterEntities(target);

View File

@ -1,6 +1,6 @@
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { ComputedPartialFieldMetadata } from './partial-field-metadata.interface';
import { ComputedPartialWorkspaceEntity } from './partial-object-metadata.interface';
@ -33,9 +33,15 @@ export interface ComparatorDeleteResult<T> {
export type ObjectComparatorResult =
| ComparatorSkipResult
| ComparatorCreateResult<Omit<ComputedPartialWorkspaceEntity, 'fields'>>
| ComparatorCreateResult<
Omit<ComputedPartialWorkspaceEntity, 'fields' | 'indexMetadatas'>
>
| ComparatorUpdateResult<
Partial<Omit<ComputedPartialWorkspaceEntity, 'fields'>> & { id: string }
Partial<
Omit<ComputedPartialWorkspaceEntity, 'fields' | 'indexMetadatas'>
> & {
id: string;
}
>;
export type FieldComparatorResult =

View File

@ -13,17 +13,22 @@ import { v4 as uuidV4 } from 'uuid';
import { PartialFieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-field-metadata.interface';
import { PartialIndexMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-index-metadata.interface';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { FieldMetadataComplexOption } from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
import {
FieldMetadataEntity,
FieldMetadataType,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { IndexFieldMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-field-metadata.entity';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory';
import { FieldMetadataUpdate } from 'src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-field.factory';
import { ObjectMetadataUpdate } from 'src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-object.factory';
import { WorkspaceSyncStorage } from 'src/engine/workspace-manager/workspace-sync-metadata/storage/workspace-sync.storage';
import { capitalize } from 'src/utils/capitalize';
@Injectable()
export class WorkspaceMetadataUpdaterService {
@ -241,10 +246,42 @@ export class WorkspaceMetadataUpdaterService {
const convertIndexFieldMetadataForSaving = (
column: string,
order: number,
) => {
): DeepPartial<IndexFieldMetadataEntity> => {
// Ensure correct type
const fieldMetadata = originalObjectMetadataCollection
.find((object) => object.id === indexMetadata.objectMetadataId)
?.fields.find((field) => column === field.name);
?.fields.find((field) => {
if (field.name === column) {
return true;
}
if (!isCompositeFieldMetadataType(field.type)) {
return;
}
const compositeType = compositeTypeDefinitions.get(
field.type as CompositeFieldMetadataType,
);
if (!compositeType) {
throw new Error(
`Composite type definition not found for type: ${field.type}`,
);
}
const columnNames = compositeType.properties.reduce(
(acc, column) => {
acc.push(`${field.name}${capitalize(column.name)}`);
return acc;
},
[] as string[],
);
if (columnNames.includes(column)) {
return true;
}
});
if (!fieldMetadata) {
throw new Error(`

View File

@ -51,7 +51,7 @@ export class WorkspaceSyncIndexMetadataService {
// We're only interested in standard fields
fields: { isCustom: false },
},
relations: ['dataSource', 'fields', 'indexes'],
relations: ['dataSource', 'fields', 'indexMetadatas'],
});
// Create map of object metadata & field metadata by unique identifier

View File

@ -1,19 +1,19 @@
import { ComputedPartialWorkspaceEntity } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-object-metadata.interface';
import { ComputedPartialFieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-field-metadata.interface';
import { ComputedPartialWorkspaceEntity } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-object-metadata.interface';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
export class WorkspaceSyncStorage {
// Object metadata
private readonly _objectMetadataCreateCollection: Omit<
ComputedPartialWorkspaceEntity,
'fields'
'fields' | 'indexMetadatas'
>[] = [];
private readonly _objectMetadataUpdateCollection: (Partial<
Omit<ComputedPartialWorkspaceEntity, 'fields'>
Omit<ComputedPartialWorkspaceEntity, 'fields' | 'indexMetadatas'>
> & {
id: string;
})[] = [];
@ -89,7 +89,7 @@ export class WorkspaceSyncStorage {
}
addCreateObjectMetadata(
object: Omit<ComputedPartialWorkspaceEntity, 'fields'>,
object: Omit<ComputedPartialWorkspaceEntity, 'fields' | 'indexMetadatas'>,
) {
this._objectMetadataCreateCollection.push(object);
}