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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
137 changed files with 22351 additions and 17974 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -136,6 +136,7 @@ describe('useCommandMenu', () => {
'ab7901eb-43e1-4dc7-8f3b-cdee2857eb9a',
imageIdentifierFieldMetadataId: null,
fields: [],
indexMetadatas: [],
},
]);
});

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect } from 'react';
import { useCallback, useEffect } from 'react';
import { ObjectMetadataItemNotFoundError } from '@/object-metadata/errors/ObjectMetadataNotFoundError';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
@ -20,7 +20,7 @@ export const PromiseRejectionEffect = () => {
},
);
} else {
enqueueSnackBar(`Error: ${event.reason}`, {
enqueueSnackBar(`${error.message}`, {
variant: SnackBarVariant.Error,
});
}

View File

@ -47,38 +47,36 @@ export const initialFavorites = [
},
];
export const sortedFavorites = [
{
"avatarType": "rounded",
"avatarUrl": "",
"id": "1",
"labelIdentifier": " ",
"link": "/object/person/1",
"position": 0,
"recordId": "1",
"workspaceMemberId": undefined,
},
{
"avatarType": "rounded",
"avatarUrl": "",
"id": "2",
"labelIdentifier": " ",
"link": "/object/person/3",
"position": 1,
"recordId": "3",
"workspaceMemberId": undefined,
},
{
"avatarType": "squared",
"avatarUrl": "example.com",
"id": "3",
"key": "8f3b2121-f194-4ba4-9fbf-2d5a37126806",
"labelIdentifier": "favoriteLabel",
"link": "example.com",
"position": 2,
"recordId": "1",
},
]
export const sortedFavorites = [
{
id: '1',
recordId: '2',
position: 0,
avatarType: 'squared',
avatarUrl: undefined,
labelIdentifier: 'ABC Corp',
link: '/object/company/2',
},
{
id: '2',
recordId: '4',
position: 1,
avatarType: 'squared',
avatarUrl: undefined,
labelIdentifier: 'Company Test',
link: '/object/company/4',
},
{
id: '3',
position: 2,
key: '8f3b2121-f194-4ba4-9fbf-2d5a37126806',
labelIdentifier: 'favoriteLabel',
avatarUrl: 'example.com',
avatarType: 'squared',
link: 'example.com',
recordId: '1',
},
];
export const mocks = [
{
@ -343,8 +341,8 @@ export const mocks = [
mutation DeleteOneFavorite($idToDelete: ID!) {
deleteFavorite(id: $idToDelete) {
__typename
deletedAt
id
deletedAt
}
}
`,

View File

@ -0,0 +1,15 @@
import { findAvailableTimeZoneOption } from '@/localization/utils/findAvailableTimeZoneOption';
describe('findAvailableTimeZoneOption', () => {
it('should find the matching available IANA time zone select option from a given IANA time zone', () => {
const ianaTimeZone = 'Europe/Paris';
const expectedOption = {
label: '(GMT+02:00) Central European Summer Time - Paris',
value: 'Europe/Paris',
};
const option = findAvailableTimeZoneOption(ianaTimeZone);
expect(option).toEqual(expectedOption);
});
});

View File

@ -6,24 +6,11 @@ import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useFindManyObjectMetadataItems } from '@/object-metadata/hooks/useFindManyObjectMetadataItems';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { WorkspaceActivationStatus } from '~/generated/graphql';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
const filterTsVectorFields = (
objectMetadataItems: ObjectMetadataItem[],
): ObjectMetadataItem[] => {
return objectMetadataItems.map((item) => ({
...item,
fields: item.fields.filter(
(field) => field.type !== FieldMetadataType.TsVector,
),
}));
};
export const ObjectMetadataItemsLoadEffect = () => {
const currentUser = useRecoilValue(currentUserState);
const currentWorkspace = useRecoilValue(currentWorkspaceState);
@ -37,13 +24,12 @@ export const ObjectMetadataItemsLoadEffect = () => {
const updateObjectMetadataItems = useRecoilCallback(
({ set, snapshot }) =>
() => {
const filteredFields = filterTsVectorFields(newObjectMetadataItems);
const toSetObjectMetadataItems =
isUndefinedOrNull(currentUser) ||
currentWorkspace?.activationStatus !==
WorkspaceActivationStatus.Active
? generatedMockObjectMetadataItems
: filteredFields;
: newObjectMetadataItems;
if (
!isDeeplyEqual(

View File

@ -24,6 +24,30 @@ export const FIND_MANY_OBJECT_METADATA_ITEMS = gql`
updatedAt
labelIdentifierFieldMetadataId
imageIdentifierFieldMetadataId
indexMetadatas(paging: { first: 100 }) {
edges {
node {
id
createdAt
updatedAt
name
indexWhereClause
indexType
isUnique
indexFieldMetadatas(paging: { first: 100 }) {
edges {
node {
id
createdAt
updatedAt
order
fieldMetadataId
}
}
}
}
}
}
fields(paging: { first: 1000 }, filter: $fieldFilter) {
edges {
node {
@ -37,6 +61,7 @@ export const FIND_MANY_OBJECT_METADATA_ITEMS = gql`
isActive
isSystem
isNullable
isUnique
createdAt
updatedAt
defaultValue

View File

@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { useQuery } from '@apollo/client';
import { useMemo } from 'react';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@ -41,12 +41,9 @@ export const useFindManyObjectMetadataItems = ({
skip: skip || !apolloMetadataClient,
onError: (error) => {
logError('useFindManyObjectMetadataItems error : ' + error);
enqueueSnackBar(
`Error during useFindManyObjectMetadataItems, ${error.message}`,
{
variant: SnackBarVariant.Error,
},
);
enqueueSnackBar(`${error.message}`, {
variant: SnackBarVariant.Error,
});
},
});

View File

@ -0,0 +1,5 @@
import { IndexField as GeneratedIndexField } from '~/generated-metadata/graphql';
export type IndexFieldMetadataItem = Omit<GeneratedIndexField, '__typename'> & {
__typename?: string;
};

View File

@ -0,0 +1,10 @@
import { IndexFieldMetadataItem } from '@/object-metadata/types/IndexFieldMetadataItem';
import { Index as GeneratedIndex } from '~/generated-metadata/graphql';
export type IndexMetadataItem = Omit<
GeneratedIndex,
'__typename' | 'indexFieldMetadatas' | 'objectMetadata'
> & {
__typename?: string;
indexFieldMetadatas: IndexFieldMetadataItem[];
};

View File

@ -1,11 +1,13 @@
import { Object as GeneratedObject } from '~/generated-metadata/graphql';
import { IndexMetadataItem } from '@/object-metadata/types/IndexMetadataItem';
import { FieldMetadataItem } from './FieldMetadataItem';
export type ObjectMetadataItem = Omit<
GeneratedObject,
'__typename' | 'fields' | 'dataSourceId'
'__typename' | 'fields' | 'dataSourceId' | 'indexMetadatas'
> & {
__typename?: string;
fields: FieldMetadataItem[];
indexMetadatas: IndexMetadataItem[];
};

View File

@ -11,6 +11,12 @@ export const mapPaginatedObjectMetadataItemsToObjectMetadataItems = ({
pagedObjectMetadataItems?.objects.edges.map((object) => ({
...object.node,
fields: object.node.fields.edges.map((field) => field.node),
indexMetadatas: object.node.indexMetadatas.edges.map((index) => ({
...index.node,
indexFieldMetadatas: index.node.indexFieldMetadatas?.edges.map(
(indexField) => indexField.node,
),
})),
})) ?? [];
return formattedObjects;

View File

@ -20,6 +20,7 @@ export const fieldMetadataItemSchema = (existingLabels?: string[]) => {
isActive: z.boolean(),
isCustom: z.boolean(),
isNullable: z.boolean(),
isUnique: z.boolean(),
isSystem: z.boolean(),
label: metadataLabelSchema(existingLabels),
name: camelCaseStringSchema,

View File

@ -0,0 +1,12 @@
import { z } from 'zod';
import { IndexFieldMetadataItem } from '@/object-metadata/types/IndexFieldMetadataItem';
export const indexFieldMetadataItemSchema = z.object({
__typename: z.literal('indexField'),
fieldMetadataId: z.string().uuid(),
id: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
order: z.number(),
}) satisfies z.ZodType<IndexFieldMetadataItem>;

View File

@ -0,0 +1,18 @@
import { z } from 'zod';
import { IndexMetadataItem } from '@/object-metadata/types/IndexMetadataItem';
import { indexFieldMetadataItemSchema } from '@/object-metadata/validation-schemas/indexFieldMetadataItemSchema';
import { IndexType } from '~/generated-metadata/graphql';
export const indexMetadataItemSchema = z.object({
__typename: z.literal('index'),
id: z.string().uuid(),
name: z.string(),
indexFieldMetadatas: z.array(indexFieldMetadataItemSchema),
createdAt: z.string(),
updatedAt: z.string(),
indexType: z.nativeEnum(IndexType),
indexWhereClause: z.string().nullable(),
isUnique: z.boolean(),
objectMetadata: z.any(),
}) satisfies z.ZodType<IndexMetadataItem>;

View File

@ -2,6 +2,7 @@ import { z } from 'zod';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { fieldMetadataItemSchema } from '@/object-metadata/validation-schemas/fieldMetadataItemSchema';
import { indexMetadataItemSchema } from '@/object-metadata/validation-schemas/indexMetadataItemSchema';
import { metadataLabelSchema } from '@/object-metadata/validation-schemas/metadataLabelSchema';
import { camelCaseStringSchema } from '~/utils/validation-schemas/camelCaseStringSchema';
@ -11,6 +12,7 @@ export const objectMetadataItemSchema = z.object({
dataSourceId: z.string().uuid(),
description: z.string().trim().nullable().optional(),
fields: z.array(fieldMetadataItemSchema()),
indexMetadatas: z.array(indexMetadataItemSchema),
icon: z.string().startsWith('Icon').trim(),
id: z.string().uuid(),
imageIdentifierFieldMetadataId: z.string().uuid().nullable(),

View File

@ -13,6 +13,7 @@ import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getDeleteManyRecordsMutationResponseField } from '@/object-record/utils/getDeleteManyRecordsMutationResponseField';
import { useRecoilValue } from 'recoil';
import { isDefined } from '~/utils/isDefined';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { sleep } from '~/utils/sleep';
import { capitalize } from '~/utils/string/capitalize';
@ -132,7 +133,7 @@ export const useDeleteManyRecords = ({
})
.catch((error: Error) => {
cachedRecords.forEach((cachedRecord) => {
if (!cachedRecord) {
if (isUndefinedOrNull(cachedRecord?.id)) {
return;
}

View File

@ -53,12 +53,9 @@ export const useFindDuplicateRecords = <T extends ObjectRecord = ObjectRecord>({
`useFindDuplicateRecords for "${objectMetadataItem.nameSingular}" error : ` +
error,
);
enqueueSnackBar(
`Error during useFindDuplicateRecords for "${objectMetadataItem.nameSingular}", ${error.message}`,
{
variant: SnackBarVariant.Error,
},
);
enqueueSnackBar(`Error finding duplicates:", ${error.message}`, {
variant: SnackBarVariant.Error,
});
},
},
);

View File

@ -19,12 +19,9 @@ export const useHandleFindManyRecordsError = ({
`useFindManyRecords for "${objectMetadataItem.namePlural}" error : ` +
error,
);
enqueueSnackBar(
`Error during useFindManyRecords for "${objectMetadataItem.namePlural}", ${error.message}`,
{
variant: SnackBarVariant.Error,
},
);
enqueueSnackBar(`${error.message}`, {
variant: SnackBarVariant.Error,
});
handleError?.(error);
};

View File

@ -11,6 +11,7 @@ import { useUpdateOneRecordMutation } from '@/object-record/hooks/useUpdateOneRe
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { capitalize } from '~/utils/string/capitalize';
type useUpdateOneRecordProps = {
@ -130,7 +131,7 @@ export const useUpdateOneRecord = <
},
})
.catch((error: Error) => {
if (!cachedRecord) {
if (isUndefinedOrNull(cachedRecord?.id)) {
throw error;
}
updateRecordFromCache({

View File

@ -13,6 +13,7 @@ const sortDefinition: SortDefinition = {
const objectMetadataItem: ObjectMetadataItem = {
id: 'object1',
fields: [],
indexMetadatas: [],
createdAt: '2021-01-01',
updatedAt: '2021-01-01',
nameSingular: 'object1',

View File

@ -156,6 +156,11 @@ export type FieldPhonesMetadata = {
fieldName: string;
};
export type FieldTsVectorMetadata = {
objectMetadataNameSingular?: string;
fieldName: string;
};
export type FieldMetadata =
| FieldBooleanMetadata
| FieldCurrencyMetadata
@ -174,8 +179,8 @@ export type FieldMetadata =
| FieldUuidMetadata
| FieldAddressMetadata
| FieldActorMetadata
| FieldArrayMetadata;
| FieldArrayMetadata
| FieldTsVectorMetadata;
export type FieldTextValue = string;
export type FieldUUidValue = string; // TODO: can we replace with a template literal type, or maybe overkill ?
export type FieldDateTimeValue = string | null;

View File

@ -0,0 +1,10 @@
import { FieldMetadata, FieldTsVectorMetadata } from '../FieldMetadata';
import { FieldDefinition } from '../FieldDefinition';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const isFieldTsVector = (
field: Pick<FieldDefinition<FieldMetadata>, 'type'>,
): field is FieldDefinition<FieldTsVectorMetadata> =>
field.type === FieldMetadataType.TsVector;

View File

@ -32,6 +32,7 @@ import { isFieldRichText } from '@/object-record/record-field/types/guards/isFie
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
import { isFieldSelectValue } from '@/object-record/record-field/types/guards/isFieldSelectValue';
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
import { isFieldTsVector } from '@/object-record/record-field/types/guards/isFieldTsVectorValue';
import { isFieldUuid } from '@/object-record/record-field/types/guards/isFieldUuid';
import { isDefined } from '~/utils/isDefined';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
@ -130,6 +131,10 @@ export const isFieldValueEmpty = ({
);
}
if (isFieldTsVector(fieldDefinition)) {
return false;
}
throw new Error(
`Entity field type not supported in isFieldValueEmpty : ${fieldDefinition.type}}`,
);

View File

@ -220,7 +220,7 @@ describe('useTableData', () => {
delayMs: 0,
viewType: ViewType.Kanban,
}),
setKanbanFieldName: useRecordBoard(recordIndexId),
useRecordBoardHook: useRecordBoard(recordIndexId),
kanbanFieldName: useRecoilValue(kanbanFieldNameState),
kanbanData: useRecordIndexOptionsForBoard({
objectNameSingular,
@ -243,7 +243,7 @@ describe('useTableData', () => {
);
await act(async () => {
result.current.setKanbanFieldName.setKanbanFieldMetadataName(
result.current.useRecordBoardHook.setKanbanFieldMetadataName(
updatedAtFieldMetadataItem?.name,
);
});
@ -278,10 +278,14 @@ describe('useTableData', () => {
relationObjectMetadataNameSingular: '',
relationType: undefined,
targetFieldMetadataName: '',
settings: {},
settings: {
displayAsRelativeDate: true,
},
},
position: expect.any(Number),
settings: {
displayAsRelativeDate: true,
},
position: 7,
settings: {},
showLabel: undefined,
size: 100,
type: 'DATE_TIME',

View File

@ -27,6 +27,7 @@ describe('useLimitPerMetadataItem', () => {
nameSingular: 'nameSingular',
updatedAt: 'updatedAt',
fields: [],
indexMetadatas: [],
},
];

View File

@ -46,6 +46,7 @@ const objectData: ObjectMetadataItem[] = [
isActive: true,
},
],
indexMetadatas: [],
},
];

View File

@ -0,0 +1,14 @@
import { IndexMetadataItem } from '@/object-metadata/types/IndexMetadataItem';
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';
export type SortedIndexByTableFamilyStateKey = {
objectMetadataItemId: string;
};
export const settingsObjectIndexesFamilyState = createFamilyState<
IndexMetadataItem[] | null,
SortedIndexByTableFamilyStateKey
>({
key: 'settingsObjectIndexesFamilyState',
defaultValue: null,
});

View File

@ -1,7 +1,7 @@
import { styled } from '@linaria/react';
const StyledEllipsisDisplay = styled.div<{ maxWidth?: number }>`
max-width: ${({ maxWidth }) => maxWidth ?? '100%'};
max-width: ${({ maxWidth }) => (maxWidth ? maxWidth + 'px' : '100%')};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

View File

@ -2,7 +2,7 @@ import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { sortedFieldByTableFamilyState } from '@/ui/layout/table/states/sortedFieldByTableFamilyState';
import { TableSortValue } from '@/ui/layout/table/types/TableSortValue';
import { useRecoilState } from 'recoil';
import { IconArrowDown, IconArrowUp } from 'twenty-ui';
import { IconArrowDown, IconArrowUp, IconComponent } from 'twenty-ui';
export const SortableTableHeader = ({
tableId,
@ -10,12 +10,14 @@ export const SortableTableHeader = ({
label,
align = 'left',
initialSort,
Icon,
}: {
tableId: string;
fieldName: string;
label: string;
align?: 'left' | 'center' | 'right';
initialSort?: TableSortValue;
Icon?: IconComponent;
}) => {
const [sortedFieldByTable, setSortedFieldByTable] = useRecoilState(
sortedFieldByTableFamilyState({ tableId }),
@ -54,6 +56,7 @@ export const SortableTableHeader = ({
<IconArrowDown size="14" />
)
) : null}
{Icon && <Icon size={14} />}
{label}
{isSortActive && align === 'left' ? (
isAsc ? (

View File

@ -1,6 +1,9 @@
import { IconComponent } from 'twenty-ui';
export type TableFieldMetadata<ItemType> = {
fieldLabel: string;
fieldName: keyof ItemType;
fieldType: 'string' | 'number';
align: 'left' | 'right';
FieldIcon?: IconComponent;
};

View File

@ -14,4 +14,5 @@ export type FeatureFlagKey =
| 'IS_QUERY_RUNNER_TWENTY_ORM_ENABLED'
| 'IS_GMAIL_SEND_EMAIL_SCOPE_ENABLED'
| 'IS_WORKSPACE_MIGRATED_FOR_SEARCH'
| 'IS_ANALYTICS_V2_ENABLED';
| 'IS_ANALYTICS_V2_ENABLED'
| 'IS_UNIQUE_INDEXES_ENABLED';

View File

@ -8,10 +8,14 @@ import { Button } from '@/ui/input/button/components/Button';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import styled from '@emotion/styled';
import { useNavigate } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { H2Title, IconPlus } from 'twenty-ui';
import { SettingsObjectFieldTable } from '~/pages/settings/data-model/SettingsObjectFieldTable';
import { SettingsObjectIndexTable } from '~/pages/settings/data-model/SettingsObjectIndexTable';
const StyledDiv = styled.div`
display: flex;
@ -40,6 +44,12 @@ export const SettingsObjectDetailPageContent = ({
const shouldDisplayAddFieldButton = !objectMetadataItem.isRemote;
const isAdvancedModeEnabled = useRecoilValue(isAdvancedModeEnabledState);
const isUniqueIndexesEnabled = useIsFeatureEnabled(
'IS_UNIQUE_INDEXES_ENABLED',
);
return (
<SubMenuTopBarContainer
title={objectMetadataItem.labelPlural}
@ -85,6 +95,15 @@ export const SettingsObjectDetailPageContent = ({
</StyledDiv>
)}
</Section>
{isAdvancedModeEnabled && isUniqueIndexesEnabled && (
<Section>
<H2Title
title="Indexes"
description={`Advanced feature to improve the performance of queries and to enforce unicity constraints.`}
/>
<SettingsObjectIndexTable objectMetadataItem={objectMetadataItem} />
</Section>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);

View File

@ -223,7 +223,17 @@ export const SettingsObjectFieldEdit = () => {
/>
</Section>
<Section>
<H2Title title="Values" description="The values of this field" />
{fieldMetadataItem.isUnique ? (
<H2Title
title="Values"
description="The values of this field must be unique"
/>
) : (
<H2Title
title="Values"
description="The values of this field"
/>
)}
<SettingsDataModelFieldSettingsFormCard
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}

View File

@ -0,0 +1,152 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { settingsObjectIndexesFamilyState } from '@/settings/data-model/object-details/states/settingsObjectIndexesFamilyState';
import { TextInput } from '@/ui/input/components/TextInput';
import { SortableTableHeader } from '@/ui/layout/table/components/SortableTableHeader';
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useSortedArray } from '@/ui/layout/table/hooks/useSortedArray';
import { TableMetadata } from '@/ui/layout/table/types/TableMetadata';
import styled from '@emotion/styled';
import { isNonEmptyArray } from '@sniptt/guards';
import { useEffect, useMemo, useState } from 'react';
import { useRecoilState } from 'recoil';
import { IconSearch, IconSquareKey } from 'twenty-ui';
import { SettingsObjectIndexesTableItem } from '~/pages/settings/data-model/types/SettingsObjectIndexesTableItem';
export const StyledObjectIndexTableRow = styled(TableRow)`
grid-template-columns: 350px 70px 80px;
`;
const SETTINGS_OBJECT_DETAIL_TABLE_METADATA_STANDARD: TableMetadata<SettingsObjectIndexesTableItem> =
{
tableId: 'settingsObjectIndexs',
fields: [
{
fieldLabel: 'Fields',
fieldName: 'indexFields',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: '',
FieldIcon: IconSquareKey,
fieldName: 'isUnique',
fieldType: 'string',
align: 'left',
},
{
fieldLabel: 'Type',
fieldName: 'indexType',
fieldType: 'string',
align: 'right',
},
],
initialSort: {
fieldName: 'name',
orderBy: 'AscNullsLast',
},
};
const StyledSearchInput = styled(TextInput)`
padding-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
export type SettingsObjectIndexTableProps = {
objectMetadataItem: ObjectMetadataItem;
};
export const SettingsObjectIndexTable = ({
objectMetadataItem,
}: SettingsObjectIndexTableProps) => {
const [searchTerm, setSearchTerm] = useState('');
const [settingsObjectIndexes, setSettingsObjectIndexes] = useRecoilState(
settingsObjectIndexesFamilyState({
objectMetadataItemId: objectMetadataItem.id,
}),
);
useEffect(() => {
setSettingsObjectIndexes(objectMetadataItem.indexMetadatas);
}, [objectMetadataItem, setSettingsObjectIndexes]);
const objectSettingsDetailItems = useMemo(() => {
return (
settingsObjectIndexes?.map((indexMetadataItem) => {
return {
name: indexMetadataItem.name,
isUnique: indexMetadataItem.isUnique,
indexType: indexMetadataItem.indexType,
indexFields: indexMetadataItem.indexFieldMetadatas
?.map((indexField) => {
const fieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.id === indexField.fieldMetadataId,
);
return fieldMetadataItem?.label;
})
.join(', '),
};
}) ?? []
);
}, [settingsObjectIndexes, objectMetadataItem]);
const sortedActiveObjectSettingsDetailItems = useSortedArray(
objectSettingsDetailItems,
SETTINGS_OBJECT_DETAIL_TABLE_METADATA_STANDARD,
);
const filteredActiveItems = useMemo(
() =>
sortedActiveObjectSettingsDetailItems.filter(
(item) =>
item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.indexType.toLowerCase().includes(searchTerm.toLowerCase()),
),
[sortedActiveObjectSettingsDetailItems, searchTerm],
);
return (
<>
<StyledSearchInput
LeftIcon={IconSearch}
placeholder="Search an index..."
value={searchTerm}
onChange={setSearchTerm}
/>
<Table>
<StyledObjectIndexTableRow>
{SETTINGS_OBJECT_DETAIL_TABLE_METADATA_STANDARD.fields.map((item) => (
<SortableTableHeader
key={item.fieldName}
fieldName={item.fieldName}
label={item.fieldLabel}
Icon={item.FieldIcon}
tableId={SETTINGS_OBJECT_DETAIL_TABLE_METADATA_STANDARD.tableId}
initialSort={
SETTINGS_OBJECT_DETAIL_TABLE_METADATA_STANDARD.initialSort
}
/>
))}
<TableHeader></TableHeader>
</StyledObjectIndexTableRow>
{isNonEmptyArray(filteredActiveItems) &&
filteredActiveItems.map((objectSettingsIndex) => (
<StyledObjectIndexTableRow key={objectSettingsIndex.name}>
<TableCell>{objectSettingsIndex.indexFields}</TableCell>
<TableCell>
{objectSettingsIndex.isUnique ? (
<IconSquareKey size={14} />
) : (
''
)}
</TableCell>
<TableCell>{objectSettingsIndex.indexType}</TableCell>
</StyledObjectIndexTableRow>
))}
</Table>
</>
);
};

View File

@ -0,0 +1,9 @@
import { IndexType } from '~/generated-metadata/graphql';
export type SettingsObjectIndexesTableItem = {
name: string;
indexType: IndexType;
isUnique: boolean;
indexWhereClause?: string | null;
indexFields: string;
};

View File

@ -5,4 +5,10 @@ export const generatedMockObjectMetadataItems: ObjectMetadataItem[] =
mockedStandardObjectMetadataQueryResult.objects.edges.map((edge) => ({
...edge.node,
fields: edge.node.fields.edges.map((edge) => edge.node),
indexMetadatas: edge.node.indexMetadatas.edges.map((index) => ({
...index.node,
indexFieldMetadatas: index.node.indexFieldMetadatas?.edges.map(
(indexField) => indexField.node,
),
})),
}));

@ -0,0 +1 @@
Subproject commit a33b01797795419edef84f122b5214472648d1ce

View File

@ -7,7 +7,7 @@ import { DataSeedDemoWorkspaceCommand } from 'src/database/commands/data-seed-de
import { DataSeedDemoWorkspaceModule } from 'src/database/commands/data-seed-demo-workspace/data-seed-demo-workspace.module';
import { DataSeedWorkspaceCommand } from 'src/database/commands/data-seed-dev-workspace.command';
import { ConfirmationQuestion } from 'src/database/commands/questions/confirmation.question';
import { UpgradeTo0_31CommandModule } from 'src/database/commands/upgrade-version/0-31/0-31-upgrade-version.module';
import { UpgradeTo0_32CommandModule } from 'src/database/commands/upgrade-version/0-32/0-32-upgrade-version.module';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
@ -46,7 +46,7 @@ import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/worksp
DataSeedDemoWorkspaceModule,
WorkspaceCacheStorageModule,
WorkspaceMetadataVersionModule,
UpgradeTo0_31CommandModule,
UpgradeTo0_32CommandModule,
FeatureFlagModule,
],
providers: [

View File

@ -1,128 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command } from 'nest-commander';
import { In, Repository } from 'typeorm';
import {
ActiveWorkspacesCommandOptions,
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity';
@Command({
name: 'upgrade-0.31:add-index-key-to-tasks-and-notes-views',
description: 'Add index key to tasks and notes views',
})
export class AddIndexKeyToTasksAndNotesViewsCommand extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(ObjectMetadataEntity, 'metadata')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
_options: ActiveWorkspacesCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log('Running command to fix migration');
for (const workspaceId of workspaceIds) {
this.logger.log(`Running command for workspace ${workspaceId}`);
try {
this.logger.log(chalk.green(`Cleaning views of ${workspaceId}.`));
await this.addIndexKeyToTasksAndNotesViews(
workspaceId,
_options.dryRun ?? false,
);
await this.twentyORMGlobalManager.destroyDataSourceForWorkspace(
workspaceId,
);
} catch (error) {
this.logger.log(
chalk.red(
`Running command on workspace ${workspaceId} failed with error: ${error}`,
),
);
continue;
} finally {
this.logger.log(
chalk.green(`Finished running command for workspace ${workspaceId}.`),
);
}
this.logger.log(chalk.green(`Command completed!`));
}
}
private async addIndexKeyToTasksAndNotesViews(
workspaceId: string,
dryRun: boolean,
): Promise<void> {
const viewRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ViewWorkspaceEntity>(
workspaceId,
'view',
false,
);
const allViews = await viewRepository.find();
const viewObjectMetadataIds = allViews.map((view) => view.objectMetadataId);
const objectMetadataEntities = await this.objectMetadataRepository.find({
where: {
id: In(viewObjectMetadataIds),
},
});
const tasksAndNotesObjectMetadataIds = objectMetadataEntities.filter(
(entity) =>
entity.standardId === STANDARD_OBJECT_IDS.task ||
entity.standardId === STANDARD_OBJECT_IDS.note,
);
const viewsToUpdate = allViews.filter(
(view) =>
tasksAndNotesObjectMetadataIds.some(
(entity) => entity.id === view.objectMetadataId,
) &&
['All Tasks', 'All Notes'].includes(view.name) &&
view.key === null,
);
if (dryRun) {
this.logger.log(
chalk.green(
`Found ${viewsToUpdate.length} views to update in workspace ${workspaceId}.`,
),
);
}
if (viewsToUpdate.length > 0 && !dryRun) {
await viewRepository.update(
viewsToUpdate.map((view) => view.id),
{
key: 'INDEX',
},
);
this.logger.log(chalk.green(`Updating ${viewsToUpdate.length} views.`));
}
if (viewsToUpdate.length === 0 && !dryRun) {
this.logger.log(chalk.green(`No views to update.`));
}
}
}

View File

@ -1,162 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command } from 'nest-commander';
import { In, Repository } from 'typeorm';
import {
ActiveWorkspacesCommandOptions,
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity';
@Command({
name: 'upgrade-0.31:backfill-workspace-favorites-migration',
description: 'Create a workspace favorite for all workspace views',
})
export class BackfillWorkspaceFavoritesCommand extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(ObjectMetadataEntity, 'metadata')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
_options: ActiveWorkspacesCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log('Running command to fix migration');
for (const workspaceId of workspaceIds) {
this.logger.log(`Running command for workspace ${workspaceId}`);
try {
const allWorkspaceIndexViews = await this.getIndexViews(workspaceId);
const activeWorkspaceIndexViews =
await this.filterViewsWithoutObjectMetadata(
workspaceId,
allWorkspaceIndexViews,
);
await this.createViewWorkspaceFavorites(
workspaceId,
activeWorkspaceIndexViews.map((view) => view.id),
_options.dryRun ?? false,
);
this.logger.log(
chalk.green(`Backfilled workspace favorites to ${workspaceId}.`),
);
await this.twentyORMGlobalManager.destroyDataSourceForWorkspace(
workspaceId,
);
} catch (error) {
this.logger.log(
chalk.red(
`Running command on workspace ${workspaceId} failed with error: ${error}`,
),
);
continue;
} finally {
this.logger.log(
chalk.green(`Finished running command for workspace ${workspaceId}.`),
);
}
this.logger.log(chalk.green(`Command completed!`));
}
}
private async getIndexViews(
workspaceId: string,
): Promise<ViewWorkspaceEntity[]> {
const viewRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ViewWorkspaceEntity>(
workspaceId,
'view',
false,
);
return viewRepository.find({
where: {
key: 'INDEX',
},
});
}
private async filterViewsWithoutObjectMetadata(
workspaceId: string,
views: ViewWorkspaceEntity[],
): Promise<ViewWorkspaceEntity[]> {
const viewObjectMetadataIds = views.map((view) => view.objectMetadataId);
const objectMetadataEntities = await this.objectMetadataRepository.find({
where: {
workspaceId,
id: In(viewObjectMetadataIds),
},
});
const objectMetadataIds = new Set(
objectMetadataEntities.map((entity) => entity.id),
);
return views.filter((view) => objectMetadataIds.has(view.objectMetadataId));
}
private async createViewWorkspaceFavorites(
workspaceId: string,
viewIds: string[],
dryRun: boolean,
) {
const favoriteRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<FavoriteWorkspaceEntity>(
workspaceId,
'favorite',
);
let nextFavoritePosition = await favoriteRepository.count();
let createdFavorites = 0;
for (const viewId of viewIds) {
const existingFavorites = await favoriteRepository.find({
where: {
viewId,
},
});
if (existingFavorites.length) {
continue;
}
if (!dryRun) {
await favoriteRepository.insert(
favoriteRepository.create({
viewId,
position: nextFavoritePosition,
}),
);
}
createdFavorites++;
nextFavoritePosition++;
}
this.logger.log(
chalk.green(
`Found ${createdFavorites} favorites to backfill in workspace ${workspaceId}.`,
),
);
}
}

View File

@ -1,119 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command } from 'nest-commander';
import { In, Repository } from 'typeorm';
import {
ActiveWorkspacesCommandOptions,
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity';
@Command({
name: 'upgrade-0.31:clean-views-associated-with-outdated-objects',
description:
'Clean views associated with deleted object metadata or activities',
})
export class CleanViewsAssociatedWithOutdatedObjectsCommand extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(ObjectMetadataEntity, 'metadata')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
_options: ActiveWorkspacesCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log('Running command to fix migration');
for (const workspaceId of workspaceIds) {
this.logger.log(`Running command for workspace ${workspaceId}`);
try {
this.logger.log(chalk.green(`Cleaning views of ${workspaceId}.`));
await this.cleanViewsWithDeletedObjectMetadata(
workspaceId,
_options.dryRun ?? false,
);
await this.twentyORMGlobalManager.destroyDataSourceForWorkspace(
workspaceId,
);
} catch (error) {
this.logger.log(
chalk.red(
`Running command on workspace ${workspaceId} failed with error: ${error}`,
),
);
continue;
} finally {
this.logger.log(
chalk.green(`Finished running command for workspace ${workspaceId}.`),
);
}
this.logger.log(chalk.green(`Command completed!`));
}
}
private async cleanViewsWithDeletedObjectMetadata(
workspaceId: string,
dryRun: boolean,
): Promise<void> {
const viewRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ViewWorkspaceEntity>(
workspaceId,
'view',
false,
);
const allViews = await viewRepository.find();
const viewObjectMetadataIds = allViews.map((view) => view.objectMetadataId);
const objectMetadataEntities = await this.objectMetadataRepository.find({
where: {
id: In(viewObjectMetadataIds),
},
});
const validObjectMetadataIds = new Set(
objectMetadataEntities
.filter((entity) => entity.standardId !== STANDARD_OBJECT_IDS.activity)
.map((entity) => entity.id),
);
const viewIdsToDelete = allViews
.filter((view) => !validObjectMetadataIds.has(view.objectMetadataId))
.map((view) => view.id);
if (dryRun) {
this.logger.log(
chalk.green(
`Found ${viewIdsToDelete.length} views to clean in workspace ${workspaceId}.`,
),
);
}
if (viewIdsToDelete.length > 0 && !dryRun) {
await viewRepository.delete(viewIdsToDelete);
this.logger.log(chalk.green(`Cleaning ${viewIdsToDelete.length} views.`));
}
if (viewIdsToDelete.length === 0) {
this.logger.log(chalk.green(`No views to clean.`));
}
}
}

View File

@ -1,121 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import {
ActiveWorkspacesCommandOptions,
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Command({
name: 'upgrade-0.31:delete-name-column-standard-object-tables',
description: 'Delete name column from standard object tables',
})
export class DeleteNameColumnStandardObjectTablesCommand extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(ObjectMetadataEntity, 'metadata')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
options: ActiveWorkspacesCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log('Running command to fix migration');
for (const workspaceId of workspaceIds) {
this.logger.log(`Running command for workspace ${workspaceId}`);
try {
this.logger.log(
chalk.green(`Deleting name columns from workspace ${workspaceId}.`),
);
const standardObjects = await this.objectMetadataRepository.find({
where: {
isCustom: false,
workspaceId,
},
relations: ['fields'],
});
const dataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace(
workspaceId,
);
dataSource.transaction(async (entityManager) => {
const queryRunner = entityManager.queryRunner;
for (const standardObject of standardObjects) {
if (options.dryRun) {
this.logger.log(
chalk.yellow(
`Dry run mode enabled. Skipping deletion of name column for workspace ${workspaceId} and table ${standardObject.nameSingular}.`,
),
);
continue;
}
const nameColumnExists = await queryRunner?.hasColumn(
standardObject.nameSingular,
'name',
);
const nameFieldMetadataExists = standardObject.fields.some(
(field) =>
field.name === 'name' && field.type === FieldMetadataType.TEXT,
);
if (nameFieldMetadataExists) {
this.logger.log(
chalk.yellow(
`Name field exists for workspace ${workspaceId} and table ${standardObject.nameSingular}. Skipping deletion.`,
),
);
continue;
}
if (!nameColumnExists) {
this.logger.log(
chalk.yellow(
`Name column does not exist for workspace ${workspaceId} and table ${standardObject.nameSingular}. Skipping deletion.`,
),
);
continue;
}
await queryRunner?.dropColumn(standardObject.nameSingular, 'name');
}
});
} catch (error) {
this.logger.log(
chalk.red(
`Running command on workspace ${workspaceId} failed with error: ${error}`,
),
);
continue;
} finally {
this.logger.log(
chalk.green(`Finished running command for workspace ${workspaceId}.`),
);
await this.twentyORMGlobalManager.destroyDataSourceForWorkspace(
workspaceId,
);
}
}
}
}

View File

@ -1,69 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command';
import { AddIndexKeyToTasksAndNotesViewsCommand } from 'src/database/commands/upgrade-version/0-31/0-31-add-index-key-to-tasks-and-notes-views.command';
import { BackfillWorkspaceFavoritesCommand } from 'src/database/commands/upgrade-version/0-31/0-31-backfill-workspace-favorites.command';
import { CleanViewsAssociatedWithOutdatedObjectsCommand } from 'src/database/commands/upgrade-version/0-31/0-31-clean-views-associated-with-outdated-objects.command';
import { DeleteNameColumnStandardObjectTablesCommand } from 'src/database/commands/upgrade-version/0-31/0-31-delete-name-column-standard-object-tables.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { SyncWorkspaceMetadataCommand } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command';
interface UpdateTo0_31CommandOptions {
workspaceId?: string;
}
@Command({
name: 'upgrade-0.31',
description: 'Upgrade to 0.31',
})
export class UpgradeTo0_31Command extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
private readonly syncWorkspaceMetadataCommand: SyncWorkspaceMetadataCommand,
private readonly backfillWorkspaceFavoritesCommand: BackfillWorkspaceFavoritesCommand,
private readonly cleanViewsAssociatedWithOutdatedObjectsCommand: CleanViewsAssociatedWithOutdatedObjectsCommand,
private readonly addIndexKeyToTasksAndNotesViewsCommand: AddIndexKeyToTasksAndNotesViewsCommand,
private readonly deleteNameColumnStandardObjectTablesCommand: DeleteNameColumnStandardObjectTablesCommand,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
passedParam: string[],
options: UpdateTo0_31CommandOptions,
workspaceIds: string[],
): Promise<void> {
await this.syncWorkspaceMetadataCommand.executeActiveWorkspacesCommand(
passedParam,
{
...options,
force: true,
},
workspaceIds,
);
await this.cleanViewsAssociatedWithOutdatedObjectsCommand.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
await this.addIndexKeyToTasksAndNotesViewsCommand.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
await this.backfillWorkspaceFavoritesCommand.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
await this.deleteNameColumnStandardObjectTablesCommand.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
}
}

View File

@ -1,27 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AddIndexKeyToTasksAndNotesViewsCommand } from 'src/database/commands/upgrade-version/0-31/0-31-add-index-key-to-tasks-and-notes-views.command';
import { BackfillWorkspaceFavoritesCommand } from 'src/database/commands/upgrade-version/0-31/0-31-backfill-workspace-favorites.command';
import { CleanViewsAssociatedWithOutdatedObjectsCommand } from 'src/database/commands/upgrade-version/0-31/0-31-clean-views-associated-with-outdated-objects.command';
import { DeleteNameColumnStandardObjectTablesCommand } from 'src/database/commands/upgrade-version/0-31/0-31-delete-name-column-standard-object-tables.command';
import { UpgradeTo0_31Command } from 'src/database/commands/upgrade-version/0-31/0-31-upgrade-version.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceSyncMetadataCommandsModule } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/workspace-sync-metadata-commands.module';
@Module({
imports: [
TypeOrmModule.forFeature([Workspace], 'core'),
TypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'),
WorkspaceSyncMetadataCommandsModule,
],
providers: [
UpgradeTo0_31Command,
BackfillWorkspaceFavoritesCommand,
CleanViewsAssociatedWithOutdatedObjectsCommand,
AddIndexKeyToTasksAndNotesViewsCommand,
DeleteNameColumnStandardObjectTablesCommand,
],
})
export class UpgradeTo0_31CommandModule {}

View File

@ -0,0 +1,303 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command, Option } from 'nest-commander';
import { IsNull, Repository } from 'typeorm';
import {
ActiveWorkspacesCommandOptions,
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
interface EnforceUniqueConstraintsCommandOptions
extends ActiveWorkspacesCommandOptions {
person?: boolean;
company?: boolean;
viewField?: boolean;
viewSort?: boolean;
}
@Command({
name: 'upgrade-0.32:enforce-unique-constraints',
description:
'Enforce unique constraints on company domainName, person emailsPrimaryEmail, ViewField, and ViewSort',
})
export class EnforceUniqueConstraintsCommand extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {
super(workspaceRepository);
}
@Option({
flags: '--person',
description: 'Enforce unique constraints on person emailsPrimaryEmail',
})
parsePerson() {
return true;
}
@Option({
flags: '--company',
description: 'Enforce unique constraints on company domainName',
})
parseCompany() {
return true;
}
@Option({
flags: '--view-field',
description: 'Enforce unique constraints on ViewField',
})
parseViewField() {
return true;
}
@Option({
flags: '--view-sort',
description: 'Enforce unique constraints on ViewSort',
})
parseViewSort() {
return true;
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
options: EnforceUniqueConstraintsCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log('Running command to enforce unique constraints');
for (const workspaceId of workspaceIds) {
this.logger.log(`Running command for workspace ${workspaceId}`);
try {
await this.enforceUniqueConstraintsForWorkspace(
workspaceId,
options.dryRun ?? false,
options,
);
await this.twentyORMGlobalManager.destroyDataSourceForWorkspace(
workspaceId,
);
} catch (error) {
this.logger.log(
chalk.red(
`Running command on workspace ${workspaceId} failed with error: ${error}, ${error.stack}`,
),
);
continue;
} finally {
this.logger.log(
chalk.green(`Finished running command for workspace ${workspaceId}.`),
);
}
}
this.logger.log(chalk.green(`Command completed!`));
}
private async enforceUniqueConstraintsForWorkspace(
workspaceId: string,
dryRun: boolean,
options: EnforceUniqueConstraintsCommandOptions,
): Promise<void> {
if (options.company) {
await this.enforceUniqueCompanyDomainName(workspaceId, dryRun);
}
if (options.person) {
await this.enforceUniquePersonEmail(workspaceId, dryRun);
}
if (options.viewField) {
await this.enforceUniqueViewField(workspaceId, dryRun);
}
if (options.viewSort) {
await this.enforceUniqueViewSort(workspaceId, dryRun);
}
}
private async enforceUniqueCompanyDomainName(
workspaceId: string,
dryRun: boolean,
): Promise<void> {
const companyRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'company',
);
const duplicates = await companyRepository
.createQueryBuilder('company')
.select('company.domainNamePrimaryLinkUrl')
.addSelect('COUNT(*)', 'count')
.where('company.deletedAt IS NULL')
.where('company.domainNamePrimaryLinkUrl IS NOT NULL')
.where("company.domainNamePrimaryLinkUrl != ''")
.groupBy('company.domainNamePrimaryLinkUrl')
.having('COUNT(*) > 1')
.getRawMany();
for (const duplicate of duplicates) {
const { company_domainNamePrimaryLinkUrl } = duplicate;
const companies = await companyRepository.find({
where: {
domainName: {
primaryLinkUrl: company_domainNamePrimaryLinkUrl,
},
deletedAt: IsNull(),
},
order: { createdAt: 'DESC' },
});
for (let i = 1; i < companies.length; i++) {
const newdomainNamePrimaryLinkUrl = `${company_domainNamePrimaryLinkUrl}${i}`;
if (!dryRun) {
await companyRepository.update(companies[i].id, {
domainNamePrimaryLinkUrl: newdomainNamePrimaryLinkUrl,
});
}
this.logger.log(
chalk.yellow(
`Updated company ${companies[i].id} domainName from ${company_domainNamePrimaryLinkUrl} to ${newdomainNamePrimaryLinkUrl}`,
),
);
}
}
}
private async enforceUniquePersonEmail(
workspaceId: string,
dryRun: boolean,
): Promise<void> {
const personRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'person',
);
const duplicates = await personRepository
.createQueryBuilder('person')
.select('person.emailsPrimaryEmail')
.addSelect('COUNT(*)', 'count')
.where('person.deletedAt IS NULL')
.where('person.emailsPrimaryEmail IS NOT NULL')
.where("person.emailsPrimaryEmail != ''")
.groupBy('person.emailsPrimaryEmail')
.having('COUNT(*) > 1')
.getRawMany();
for (const duplicate of duplicates) {
const { person_emailsPrimaryEmail } = duplicate;
const persons = await personRepository.find({
where: {
emails: {
primaryEmail: person_emailsPrimaryEmail,
},
deletedAt: IsNull(),
},
order: { createdAt: 'DESC' },
});
for (let i = 1; i < persons.length; i++) {
const newEmail = person_emailsPrimaryEmail?.includes('@')
? `${person_emailsPrimaryEmail.split('@')[0]}+${i}@${person_emailsPrimaryEmail.split('@')[1]}`
: `${person_emailsPrimaryEmail}+${i}`;
if (!dryRun) {
await personRepository.update(persons[i].id, {
emailsPrimaryEmail: newEmail,
});
}
this.logger.log(
chalk.yellow(
`Updated person ${persons[i].id} emailsPrimaryEmail from ${person_emailsPrimaryEmail} to ${newEmail}`,
),
);
}
}
}
private async enforceUniqueViewField(
workspaceId: string,
dryRun: boolean,
): Promise<void> {
const viewFieldRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'viewField',
);
const duplicates = await viewFieldRepository
.createQueryBuilder('viewField')
.select(['viewField.fieldMetadataId', 'viewField.viewId'])
.addSelect('COUNT(*)', 'count')
.where('viewField.deletedAt IS NULL')
.groupBy('viewField.fieldMetadataId, viewField.viewId')
.having('COUNT(*) > 1')
.getRawMany();
for (const duplicate of duplicates) {
const { fieldMetadataId, viewId } = duplicate;
const viewFields = await viewFieldRepository.find({
where: { fieldMetadataId, viewId, deletedAt: IsNull() },
order: { createdAt: 'DESC' },
});
for (let i = 1; i < viewFields.length; i++) {
if (!dryRun) {
await viewFieldRepository.softDelete(viewFields[i].id);
}
this.logger.log(
chalk.yellow(
`Soft deleted duplicate ViewField ${viewFields[i].id} for fieldMetadataId ${fieldMetadataId} and viewId ${viewId}`,
),
);
}
}
}
private async enforceUniqueViewSort(
workspaceId: string,
dryRun: boolean,
): Promise<void> {
const viewSortRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'viewSort',
);
const duplicates = await viewSortRepository
.createQueryBuilder('viewSort')
.select(['viewSort.fieldMetadataId', 'viewSort.viewId'])
.addSelect('COUNT(*)', 'count')
.where('viewSort.deletedAt IS NULL')
.groupBy('viewSort.fieldMetadataId, viewSort.viewId')
.having('COUNT(*) > 1')
.getRawMany();
for (const duplicate of duplicates) {
const { fieldMetadataId, viewId } = duplicate;
const viewSorts = await viewSortRepository.find({
where: { fieldMetadataId, viewId, deletedAt: IsNull() },
order: { createdAt: 'DESC' },
});
for (let i = 1; i < viewSorts.length; i++) {
if (!dryRun) {
await viewSortRepository.softDelete(viewSorts[i].id);
}
this.logger.log(
chalk.yellow(
`Soft deleted duplicate ViewSort ${viewSorts[i].id} for fieldMetadataId ${fieldMetadataId} and viewId ${viewId}`,
),
);
}
}
}
}

View File

@ -0,0 +1,50 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { SyncWorkspaceMetadataCommand } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command';
import { EnforceUniqueConstraintsCommand } from './0-32-enforce-unique-constraints.command';
interface UpdateTo0_32CommandOptions {
workspaceId?: string;
}
@Command({
name: 'upgrade-0.32',
description: 'Upgrade to 0.32',
})
export class UpgradeTo0_32Command extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
private readonly syncWorkspaceMetadataCommand: SyncWorkspaceMetadataCommand,
private readonly enforceUniqueConstraintsCommand: EnforceUniqueConstraintsCommand,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
passedParam: string[],
options: UpdateTo0_32CommandOptions,
workspaceIds: string[],
): Promise<void> {
await this.syncWorkspaceMetadataCommand.executeActiveWorkspacesCommand(
passedParam,
{
...options,
force: true,
},
workspaceIds,
);
await this.enforceUniqueConstraintsCommand.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
}
}

View File

@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EnforceUniqueConstraintsCommand } from 'src/database/commands/upgrade-version/0-32/0-32-enforce-unique-constraints.command';
import { UpgradeTo0_32Command } from 'src/database/commands/upgrade-version/0-32/0-32-upgrade-version.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceSyncMetadataCommandsModule } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/workspace-sync-metadata-commands.module';
@Module({
imports: [
TypeOrmModule.forFeature([Workspace], 'core'),
TypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'),
WorkspaceSyncMetadataCommandsModule,
],
providers: [UpgradeTo0_32Command, EnforceUniqueConstraintsCommand],
})
export class UpgradeTo0_32CommandModule {}

View File

@ -43,7 +43,7 @@ export const seedFeatureFlags = async (
{
key: FeatureFlagKey.IsWorkflowEnabled,
workspaceId: workspaceId,
value: false,
value: true,
},
{
key: FeatureFlagKey.IsMessageThreadSubscriberEnabled,
@ -53,7 +53,7 @@ export const seedFeatureFlags = async (
{
key: FeatureFlagKey.IsWorkspaceFavoriteEnabled,
workspaceId: workspaceId,
value: false,
value: true,
},
{
key: FeatureFlagKey.IsSearchEnabled,
@ -75,6 +75,11 @@ export const seedFeatureFlags = async (
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IsUniqueIndexesEnabled,
workspaceId: workspaceId,
value: false,
},
])
.execute();
};

View File

@ -15,6 +15,7 @@ export const getDevSeedCompanyCustomFields = (
icon: 'IconAdCircle',
isActive: true,
isNullable: false,
isUnique: false,
defaultValue: "''",
objectMetadataId,
},
@ -27,6 +28,7 @@ export const getDevSeedCompanyCustomFields = (
icon: 'IconVideo',
isActive: true,
isNullable: true,
isUnique: false,
objectMetadataId,
},
{
@ -38,6 +40,7 @@ export const getDevSeedCompanyCustomFields = (
icon: 'IconHome',
isActive: true,
isNullable: true,
isUnique: false,
objectMetadataId,
options: [
{
@ -69,6 +72,7 @@ export const getDevSeedCompanyCustomFields = (
icon: 'IconBrandVisa',
isActive: true,
isNullable: true,
isUnique: false,
objectMetadataId,
defaultValue: false,
},
@ -89,6 +93,7 @@ export const getDevSeedPeopleCustomFields = (
icon: 'IconNote',
isActive: true,
isNullable: true,
isUnique: false,
objectMetadataId,
},
{
@ -100,6 +105,7 @@ export const getDevSeedPeopleCustomFields = (
icon: 'IconBrandWhatsapp',
isActive: true,
isNullable: false,
isUnique: false,
defaultValue: [
{
primaryPhoneNumber: '',
@ -118,6 +124,7 @@ export const getDevSeedPeopleCustomFields = (
icon: 'IconHome',
isActive: true,
isNullable: true,
isUnique: false,
objectMetadataId,
options: [
{
@ -149,6 +156,7 @@ export const getDevSeedPeopleCustomFields = (
icon: 'IconStars',
isActive: true,
isNullable: true,
isUnique: false,
objectMetadataId,
options: [
{

View File

@ -0,0 +1,53 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class MigrationDebt1726757368824 implements MigrationInterface {
name = 'MigrationDebt1726757368824';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TYPE "metadata"."relationMetadata_ondeleteaction_enum" RENAME TO "relationMetadata_ondeleteaction_enum_old"`,
);
await queryRunner.query(
`CREATE TYPE "metadata"."relationMetadata_ondeleteaction_enum" AS ENUM('CASCADE', 'RESTRICT', 'SET_NULL', 'NO_ACTION')`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."relationMetadata" ALTER COLUMN "onDeleteAction" DROP DEFAULT`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."relationMetadata" ALTER COLUMN "onDeleteAction" TYPE "metadata"."relationMetadata_ondeleteaction_enum" USING "onDeleteAction"::"text"::"metadata"."relationMetadata_ondeleteaction_enum"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."relationMetadata" ALTER COLUMN "onDeleteAction" SET DEFAULT 'SET_NULL'`,
);
await queryRunner.query(
`DROP TYPE "metadata"."relationMetadata_ondeleteaction_enum_old"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."workspaceMigration" ALTER COLUMN "name" SET NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."workspaceMigration" ALTER COLUMN "name" DROP NOT NULL`,
);
await queryRunner.query(
`CREATE TYPE "metadata"."relationMetadata_ondeleteaction_enum_old" AS ENUM('CASCADE', 'RESTRICT', 'SET_NULL')`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."relationMetadata" ALTER COLUMN "onDeleteAction" DROP DEFAULT`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."relationMetadata" ALTER COLUMN "onDeleteAction" TYPE "metadata"."relationMetadata_ondeleteaction_enum_old" USING "onDeleteAction"::"text"::"metadata"."relationMetadata_ondeleteaction_enum_old"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."relationMetadata" ALTER COLUMN "onDeleteAction" SET DEFAULT 'SET_NULL'`,
);
await queryRunner.query(
`DROP TYPE "metadata"."relationMetadata_ondeleteaction_enum"`,
);
await queryRunner.query(
`ALTER TYPE "metadata"."relationMetadata_ondeleteaction_enum_old" RENAME TO "relationMetadata_ondeleteaction_enum"`,
);
}
}

View File

@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddIsUniqueToIndexMetadata1726757368825
implements MigrationInterface
{
name = 'AddIsUniqueToIndexMetadata1726757368825';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."indexMetadata" ADD "isUnique" boolean NOT NULL DEFAULT false`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."indexMetadata" DROP COLUMN "isUnique"`,
);
}
}

View File

@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddWhereToIndexMetadata1726766871572
implements MigrationInterface
{
name = 'AddWhereToIndexMetadata1726766871572';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."indexMetadata" ADD "indexWhereClause" text`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."indexMetadata" DROP COLUMN "indexWhereClause"`,
);
}
}

View File

@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddIsUniqueToFields1728563893694 implements MigrationInterface {
name = 'AddIsUniqueToFields1728563893694';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."fieldMetadata" ADD "isUnique" boolean DEFAULT false`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."indexMetadata" DROP COLUMN "indexWhereClause"`,
);
}
}

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;

Some files were not shown because too many files have changed in this diff Show More