feat: refactor folder structure (#4498)
* feat: wip refactor folder structure * Fix * fix position --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,26 @@
|
||||
import { EntityManager } from 'typeorm';
|
||||
|
||||
import companiesDemo from './companies-demo.json';
|
||||
|
||||
export const companyPrefillData = async (
|
||||
entityManager: EntityManager,
|
||||
schemaName: string,
|
||||
) => {
|
||||
await entityManager
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(`${schemaName}.company`, [
|
||||
'name',
|
||||
'domainName',
|
||||
'address',
|
||||
'employees',
|
||||
'linkedinLinkUrl',
|
||||
'position',
|
||||
])
|
||||
.orIgnore()
|
||||
.values(
|
||||
companiesDemo.map((company, index) => ({ ...company, position: index })),
|
||||
)
|
||||
.returning('*')
|
||||
.execute();
|
||||
};
|
||||
@ -0,0 +1,43 @@
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { viewPrefillData } from 'src/engine/workspace-manager/demo-objects-prefill-data/view';
|
||||
import { companyPrefillData } from 'src/engine/workspace-manager/demo-objects-prefill-data/company';
|
||||
import { personPrefillData } from 'src/engine/workspace-manager/demo-objects-prefill-data/person';
|
||||
import { pipelineStepPrefillData } from 'src/engine/workspace-manager/demo-objects-prefill-data/pipeline-step';
|
||||
import { workspaceMemberPrefillData } from 'src/engine/workspace-manager/demo-objects-prefill-data/workspace-member';
|
||||
import { seedDemoOpportunity } from 'src/engine/workspace-manager/demo-objects-prefill-data/opportunity';
|
||||
|
||||
export const demoObjectsPrefillData = async (
|
||||
workspaceDataSource: DataSource,
|
||||
schemaName: string,
|
||||
objectMetadata: ObjectMetadataEntity[],
|
||||
) => {
|
||||
const objectMetadataMap = objectMetadata.reduce((acc, object) => {
|
||||
acc[object.nameSingular] = {
|
||||
id: object.id,
|
||||
fields: object.fields.reduce((acc, field) => {
|
||||
acc[field.name] = field.id;
|
||||
|
||||
return acc;
|
||||
}, {}),
|
||||
};
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// TODO: udnerstand why only with this createQueryRunner transaction below works
|
||||
const queryRunner = workspaceDataSource.createQueryRunner();
|
||||
|
||||
await queryRunner.connect();
|
||||
|
||||
workspaceDataSource.transaction(async (entityManager: EntityManager) => {
|
||||
await companyPrefillData(entityManager, schemaName);
|
||||
await personPrefillData(entityManager, schemaName);
|
||||
await viewPrefillData(entityManager, schemaName, objectMetadataMap);
|
||||
await pipelineStepPrefillData(entityManager, schemaName);
|
||||
await seedDemoOpportunity(entityManager, schemaName);
|
||||
|
||||
await workspaceMemberPrefillData(entityManager, schemaName);
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,90 @@
|
||||
import { EntityManager } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
const tableName = 'opportunity';
|
||||
|
||||
const getRandomProbability = () => {
|
||||
const firstDigit = Math.floor(Math.random() * 9) + 1;
|
||||
|
||||
return firstDigit / 10;
|
||||
};
|
||||
|
||||
const getRandomPipelineStepId = (pipelineStepIds: { id: string }[]) =>
|
||||
pipelineStepIds[Math.floor(Math.random() * pipelineStepIds.length)].id;
|
||||
|
||||
const getRandomStage = () => {
|
||||
const stages = ['NEW', 'SCREENING', 'MEETING', 'PROPOSAL', 'CUSTOMER'];
|
||||
|
||||
return stages[Math.floor(Math.random() * stages.length)];
|
||||
};
|
||||
|
||||
const generateRandomAmountMicros = () => {
|
||||
const firstDigit = Math.floor(Math.random() * 9) + 1;
|
||||
|
||||
return firstDigit * 10000000000;
|
||||
};
|
||||
|
||||
// Function to generate the array of opportunities
|
||||
// companiesWithPeople - selecting from the db companies and 1 person related to the company.id to use companyId, pointOfContactId and personId
|
||||
// pipelineStepIds - selecting from the db pipeline, getting random id from selected to use as pipelineStepId
|
||||
|
||||
const generateOpportunities = (
|
||||
companies,
|
||||
pipelineStepIds: { id: string }[],
|
||||
) => {
|
||||
return companies.map((company) => ({
|
||||
id: v4(),
|
||||
amountAmountMicros: generateRandomAmountMicros(),
|
||||
amountCurrencyCode: 'USD',
|
||||
closeDate: new Date(),
|
||||
stage: getRandomStage(),
|
||||
probability: getRandomProbability(),
|
||||
pipelineStepId: getRandomPipelineStepId(pipelineStepIds),
|
||||
pointOfContactId: company.personId,
|
||||
companyId: company.id,
|
||||
}));
|
||||
};
|
||||
|
||||
export const seedDemoOpportunity = async (
|
||||
entityManager: EntityManager,
|
||||
schemaName: string,
|
||||
) => {
|
||||
const companiesWithPeople = await entityManager?.query(
|
||||
`SELECT company.*, person.id AS "personId"
|
||||
FROM ${schemaName}.company
|
||||
LEFT JOIN ${schemaName}.person ON company.id = "person"."companyId"
|
||||
LIMIT 50`,
|
||||
);
|
||||
const pipelineStepIds = await entityManager?.query(
|
||||
`SELECT id FROM ${schemaName}."pipelineStep"`,
|
||||
);
|
||||
|
||||
const opportunities = generateOpportunities(
|
||||
companiesWithPeople,
|
||||
pipelineStepIds,
|
||||
);
|
||||
|
||||
await entityManager
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(`${schemaName}.${tableName}`, [
|
||||
'id',
|
||||
'amountAmountMicros',
|
||||
'amountCurrencyCode',
|
||||
'closeDate',
|
||||
'stage',
|
||||
'probability',
|
||||
'pipelineStepId',
|
||||
'pointOfContactId',
|
||||
'companyId',
|
||||
'position',
|
||||
])
|
||||
.orIgnore()
|
||||
.values(
|
||||
opportunities.map((opportunity, index) => ({
|
||||
...opportunity,
|
||||
position: index,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,44 @@
|
||||
import { EntityManager } from 'typeorm';
|
||||
|
||||
import peopleDemo from './people-demo.json';
|
||||
|
||||
export const personPrefillData = async (
|
||||
entityManager: EntityManager,
|
||||
schemaName: string,
|
||||
) => {
|
||||
const companies = await entityManager?.query(
|
||||
`SELECT * FROM ${schemaName}.company`,
|
||||
);
|
||||
|
||||
// Iterate through the array and add a UUID for each person
|
||||
const people = peopleDemo.map((person, index) => ({
|
||||
nameFirstName: person.firstName,
|
||||
nameLastName: person.lastName,
|
||||
email: person.email,
|
||||
linkedinLinkUrl: person.linkedinUrl,
|
||||
jobTitle: person.jobTitle,
|
||||
city: person.city,
|
||||
avatarUrl: person.avatarUrl,
|
||||
position: index,
|
||||
companyId: companies[Math.floor(index / 2)].id,
|
||||
}));
|
||||
|
||||
await entityManager
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(`${schemaName}.person`, [
|
||||
'nameFirstName',
|
||||
'nameLastName',
|
||||
'email',
|
||||
'linkedinLinkUrl',
|
||||
'jobTitle',
|
||||
'city',
|
||||
'avatarUrl',
|
||||
'position',
|
||||
'companyId',
|
||||
])
|
||||
.orIgnore()
|
||||
.values(people)
|
||||
.returning('*')
|
||||
.execute();
|
||||
};
|
||||
@ -0,0 +1,41 @@
|
||||
import { EntityManager } from 'typeorm';
|
||||
|
||||
export const pipelineStepPrefillData = async (
|
||||
entityManager: EntityManager,
|
||||
schemaName: string,
|
||||
) => {
|
||||
await entityManager
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(`${schemaName}.pipelineStep`, ['name', 'color', 'position'])
|
||||
.orIgnore()
|
||||
.values([
|
||||
{
|
||||
name: 'NEW',
|
||||
color: 'red',
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
name: 'SCREENING',
|
||||
color: 'purple',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
name: 'MEETING',
|
||||
color: 'sky',
|
||||
position: 2,
|
||||
},
|
||||
{
|
||||
name: 'PROPOSAL',
|
||||
color: 'turquoise',
|
||||
position: 3,
|
||||
},
|
||||
{
|
||||
name: 'CUSTOMER',
|
||||
color: 'yellow',
|
||||
position: 4,
|
||||
},
|
||||
])
|
||||
.returning('*')
|
||||
.execute();
|
||||
};
|
||||
@ -0,0 +1,269 @@
|
||||
import { EntityManager } from 'typeorm';
|
||||
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
|
||||
export const viewPrefillData = async (
|
||||
entityManager: EntityManager,
|
||||
schemaName: string,
|
||||
objectMetadataMap: Record<string, ObjectMetadataEntity>,
|
||||
) => {
|
||||
// Creating views
|
||||
const createdViews = await entityManager
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(`${schemaName}.view`, [
|
||||
'name',
|
||||
'objectMetadataId',
|
||||
'type',
|
||||
'key',
|
||||
'position',
|
||||
'icon',
|
||||
])
|
||||
.orIgnore()
|
||||
.values([
|
||||
{
|
||||
name: 'All Companies',
|
||||
objectMetadataId: objectMetadataMap['company'].id,
|
||||
type: 'table',
|
||||
key: 'INDEX',
|
||||
position: 0,
|
||||
icon: 'IconBuildingSkyscraper',
|
||||
},
|
||||
{
|
||||
name: 'All People',
|
||||
objectMetadataId: objectMetadataMap['person'].id,
|
||||
type: 'table',
|
||||
key: 'INDEX',
|
||||
position: 0,
|
||||
icon: 'IconUser',
|
||||
},
|
||||
{
|
||||
name: 'By Stage',
|
||||
objectMetadataId: objectMetadataMap['opportunity'].id,
|
||||
type: 'kanban',
|
||||
key: null,
|
||||
position: 0,
|
||||
icon: 'IconLayoutKanban',
|
||||
},
|
||||
{
|
||||
name: 'All Opportunities',
|
||||
objectMetadataId: objectMetadataMap['opportunity'].id,
|
||||
type: 'table',
|
||||
key: 'INDEX',
|
||||
position: 1,
|
||||
icon: 'IconTargetArrow',
|
||||
},
|
||||
])
|
||||
.returning('*')
|
||||
.execute();
|
||||
|
||||
const viewIdMap = createdViews.raw.reduce((acc, view) => {
|
||||
acc[view.name] = view.id;
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Creating viewFields
|
||||
await entityManager
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(`${schemaName}.viewField`, [
|
||||
'fieldMetadataId',
|
||||
'viewId',
|
||||
'position',
|
||||
'isVisible',
|
||||
'size',
|
||||
])
|
||||
.orIgnore()
|
||||
.values([
|
||||
// Company
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['company'].fields['name'],
|
||||
viewId: viewIdMap['All Companies'],
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
size: 180,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['company'].fields['domainName'],
|
||||
viewId: viewIdMap['All Companies'],
|
||||
position: 1,
|
||||
isVisible: true,
|
||||
size: 100,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['company'].fields['accountOwner'],
|
||||
viewId: viewIdMap['All Companies'],
|
||||
position: 2,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['company'].fields['createdAt'],
|
||||
viewId: viewIdMap['All Companies'],
|
||||
position: 3,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['company'].fields['employees'],
|
||||
viewId: viewIdMap['All Companies'],
|
||||
position: 4,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['company'].fields['linkedinLink'],
|
||||
viewId: viewIdMap['All Companies'],
|
||||
position: 5,
|
||||
isVisible: true,
|
||||
size: 170,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['company'].fields['address'],
|
||||
viewId: viewIdMap['All Companies'],
|
||||
position: 6,
|
||||
isVisible: true,
|
||||
size: 170,
|
||||
},
|
||||
// Person
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['person'].fields['name'],
|
||||
viewId: viewIdMap['All People'],
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
size: 210,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['person'].fields['email'],
|
||||
viewId: viewIdMap['All People'],
|
||||
position: 1,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['person'].fields['company'],
|
||||
viewId: viewIdMap['All People'],
|
||||
position: 2,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['person'].fields['phone'],
|
||||
viewId: viewIdMap['All People'],
|
||||
position: 3,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['person'].fields['createdAt'],
|
||||
viewId: viewIdMap['All People'],
|
||||
position: 4,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['person'].fields['city'],
|
||||
viewId: viewIdMap['All People'],
|
||||
position: 5,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['person'].fields['jobTitle'],
|
||||
viewId: viewIdMap['All People'],
|
||||
position: 6,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['person'].fields['linkedinLink'],
|
||||
viewId: viewIdMap['All People'],
|
||||
position: 7,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['person'].fields['xLink'],
|
||||
viewId: viewIdMap['All People'],
|
||||
position: 8,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
// Opportunity
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['opportunity'].fields['name'],
|
||||
viewId: viewIdMap['All Opportunities'],
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['opportunity'].fields['amount'],
|
||||
viewId: viewIdMap['All Opportunities'],
|
||||
position: 1,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['opportunity'].fields['closeDate'],
|
||||
viewId: viewIdMap['All Opportunities'],
|
||||
position: 2,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['opportunity'].fields['probability'],
|
||||
viewId: viewIdMap['All Opportunities'],
|
||||
position: 3,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId:
|
||||
objectMetadataMap['opportunity'].fields['pointOfContact'],
|
||||
viewId: viewIdMap['All Opportunities'],
|
||||
position: 4,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['opportunity'].fields['name'],
|
||||
viewId: viewIdMap['By Stage'],
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['opportunity'].fields['amount'],
|
||||
viewId: viewIdMap['By Stage'],
|
||||
position: 1,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['opportunity'].fields['closeDate'],
|
||||
viewId: viewIdMap['By Stage'],
|
||||
position: 2,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['opportunity'].fields['probability'],
|
||||
viewId: viewIdMap['By Stage'],
|
||||
position: 3,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId:
|
||||
objectMetadataMap['opportunity'].fields['pointOfContact'],
|
||||
viewId: viewIdMap['By Stage'],
|
||||
position: 4,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
};
|
||||
@ -0,0 +1,54 @@
|
||||
import { EntityManager } from 'typeorm';
|
||||
|
||||
import { DemoSeedUserIds } from 'src/database/typeorm-seeds/core/demo/users';
|
||||
|
||||
const WorkspaceMemberIds = {
|
||||
Noah: '20202020-0687-4c41-b707-ed1bfca972a7',
|
||||
Hugo: '20202020-77d5-4cb6-b60a-f4a835a85d61',
|
||||
Julia: '20202020-1553-45c6-a028-5a9064cce07f',
|
||||
};
|
||||
|
||||
export const workspaceMemberPrefillData = async (
|
||||
entityManager: EntityManager,
|
||||
schemaName: string,
|
||||
) => {
|
||||
await entityManager
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(`${schemaName}.workspaceMember`, [
|
||||
'id',
|
||||
'nameFirstName',
|
||||
'nameLastName',
|
||||
'locale',
|
||||
'colorScheme',
|
||||
'userId',
|
||||
])
|
||||
.orIgnore()
|
||||
.values([
|
||||
{
|
||||
id: WorkspaceMemberIds.Noah,
|
||||
nameFirstName: 'Noah',
|
||||
nameLastName: 'A',
|
||||
locale: 'en',
|
||||
colorScheme: 'Light',
|
||||
userId: DemoSeedUserIds.Noah,
|
||||
},
|
||||
{
|
||||
id: WorkspaceMemberIds.Hugo,
|
||||
nameFirstName: 'Hugo',
|
||||
nameLastName: 'I',
|
||||
locale: 'en',
|
||||
colorScheme: 'Light',
|
||||
userId: DemoSeedUserIds.Hugo,
|
||||
},
|
||||
{
|
||||
id: WorkspaceMemberIds.Julia,
|
||||
nameFirstName: 'Julia',
|
||||
nameLastName: 'S',
|
||||
locale: 'en',
|
||||
colorScheme: 'Light',
|
||||
userId: DemoSeedUserIds.Julia,
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
};
|
||||
@ -0,0 +1,57 @@
|
||||
import { EntityManager } from 'typeorm';
|
||||
|
||||
export const companyPrefillData = async (
|
||||
entityManager: EntityManager,
|
||||
schemaName: string,
|
||||
) => {
|
||||
await entityManager
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(`${schemaName}.company`, [
|
||||
'name',
|
||||
'domainName',
|
||||
'address',
|
||||
'employees',
|
||||
'position',
|
||||
])
|
||||
.orIgnore()
|
||||
.values([
|
||||
{
|
||||
name: 'Airbnb',
|
||||
domainName: 'airbnb.com',
|
||||
address: 'San Francisco',
|
||||
employees: 5000,
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
name: 'Qonto',
|
||||
domainName: 'qonto.com',
|
||||
address: 'San Francisco',
|
||||
employees: 800,
|
||||
position: 2,
|
||||
},
|
||||
{
|
||||
name: 'Stripe',
|
||||
domainName: 'stripe.com',
|
||||
address: 'San Francisco',
|
||||
employees: 8000,
|
||||
position: 3,
|
||||
},
|
||||
{
|
||||
name: 'Figma',
|
||||
domainName: 'figma.com',
|
||||
address: 'San Francisco',
|
||||
employees: 800,
|
||||
position: 4,
|
||||
},
|
||||
{
|
||||
name: 'Notion',
|
||||
domainName: 'notion.com',
|
||||
address: 'San Francisco',
|
||||
employees: 400,
|
||||
position: 5,
|
||||
},
|
||||
])
|
||||
.returning('*')
|
||||
.execute();
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,41 @@
|
||||
import { EntityManager } from 'typeorm';
|
||||
|
||||
export const pipelineStepPrefillData = async (
|
||||
entityManager: EntityManager,
|
||||
schemaName: string,
|
||||
) => {
|
||||
await entityManager
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(`${schemaName}.pipelineStep`, ['name', 'color', 'position'])
|
||||
.orIgnore()
|
||||
.values([
|
||||
{
|
||||
name: 'NEW',
|
||||
color: 'red',
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
name: 'SCREENING',
|
||||
color: 'purple',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
name: 'MEETING',
|
||||
color: 'sky',
|
||||
position: 2,
|
||||
},
|
||||
{
|
||||
name: 'PROPOSAL',
|
||||
color: 'turquoise',
|
||||
position: 3,
|
||||
},
|
||||
{
|
||||
name: 'CUSTOMER',
|
||||
color: 'yellow',
|
||||
position: 4,
|
||||
},
|
||||
])
|
||||
.returning('*')
|
||||
.execute();
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { viewPrefillData } from 'src/engine/workspace-manager/standard-objects-prefill-data/view';
|
||||
import { companyPrefillData } from 'src/engine/workspace-manager/standard-objects-prefill-data/company';
|
||||
import { personPrefillData } from 'src/engine/workspace-manager/standard-objects-prefill-data/person';
|
||||
import { pipelineStepPrefillData } from 'src/engine/workspace-manager/standard-objects-prefill-data/pipeline-step';
|
||||
|
||||
export const standardObjectsPrefillData = async (
|
||||
workspaceDataSource: DataSource,
|
||||
schemaName: string,
|
||||
objectMetadata: ObjectMetadataEntity[],
|
||||
) => {
|
||||
const objectMetadataMap = objectMetadata.reduce((acc, object) => {
|
||||
acc[object.nameSingular] = {
|
||||
id: object.id,
|
||||
fields: object.fields.reduce((acc, field) => {
|
||||
acc[field.name] = field.id;
|
||||
|
||||
return acc;
|
||||
}, {}),
|
||||
};
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
workspaceDataSource.transaction(async (entityManager: EntityManager) => {
|
||||
await companyPrefillData(entityManager, schemaName);
|
||||
await personPrefillData(entityManager, schemaName);
|
||||
await viewPrefillData(entityManager, schemaName, objectMetadataMap);
|
||||
await pipelineStepPrefillData(entityManager, schemaName);
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,269 @@
|
||||
import { EntityManager } from 'typeorm';
|
||||
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
|
||||
export const viewPrefillData = async (
|
||||
entityManager: EntityManager,
|
||||
schemaName: string,
|
||||
objectMetadataMap: Record<string, ObjectMetadataEntity>,
|
||||
) => {
|
||||
// Creating views
|
||||
const createdViews = await entityManager
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(`${schemaName}.view`, [
|
||||
'name',
|
||||
'objectMetadataId',
|
||||
'type',
|
||||
'key',
|
||||
'position',
|
||||
'icon',
|
||||
])
|
||||
.orIgnore()
|
||||
.values([
|
||||
{
|
||||
name: 'All Companies',
|
||||
objectMetadataId: objectMetadataMap['company'].id,
|
||||
type: 'table',
|
||||
key: 'INDEX',
|
||||
position: 0,
|
||||
icon: 'IconBuildingSkyscraper',
|
||||
},
|
||||
{
|
||||
name: 'All People',
|
||||
objectMetadataId: objectMetadataMap['person'].id,
|
||||
type: 'table',
|
||||
key: 'INDEX',
|
||||
position: 0,
|
||||
icon: 'IconUser',
|
||||
},
|
||||
{
|
||||
name: 'By Stage',
|
||||
objectMetadataId: objectMetadataMap['opportunity'].id,
|
||||
type: 'kanban',
|
||||
key: null,
|
||||
position: 0,
|
||||
icon: 'IconLayoutKanban',
|
||||
},
|
||||
{
|
||||
name: 'All Opportunities',
|
||||
objectMetadataId: objectMetadataMap['opportunity'].id,
|
||||
type: 'table',
|
||||
key: 'INDEX',
|
||||
position: 1,
|
||||
icon: 'IconTargetArrow',
|
||||
},
|
||||
])
|
||||
.returning('*')
|
||||
.execute();
|
||||
|
||||
const viewIdMap = createdViews.raw.reduce((acc, view) => {
|
||||
acc[view.name] = view.id;
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Creating viewFields
|
||||
await entityManager
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(`${schemaName}.viewField`, [
|
||||
'fieldMetadataId',
|
||||
'viewId',
|
||||
'position',
|
||||
'isVisible',
|
||||
'size',
|
||||
])
|
||||
.orIgnore()
|
||||
.values([
|
||||
// Company
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['company'].fields['name'],
|
||||
viewId: viewIdMap['All Companies'],
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
size: 180,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['company'].fields['domainName'],
|
||||
viewId: viewIdMap['All Companies'],
|
||||
position: 1,
|
||||
isVisible: true,
|
||||
size: 100,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['company'].fields['accountOwner'],
|
||||
viewId: viewIdMap['All Companies'],
|
||||
position: 2,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['company'].fields['createdAt'],
|
||||
viewId: viewIdMap['All Companies'],
|
||||
position: 3,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['company'].fields['employees'],
|
||||
viewId: viewIdMap['All Companies'],
|
||||
position: 4,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['company'].fields['linkedinLink'],
|
||||
viewId: viewIdMap['All Companies'],
|
||||
position: 5,
|
||||
isVisible: true,
|
||||
size: 170,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['company'].fields['address'],
|
||||
viewId: viewIdMap['All Companies'],
|
||||
position: 6,
|
||||
isVisible: true,
|
||||
size: 170,
|
||||
},
|
||||
// Person
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['person'].fields['name'],
|
||||
viewId: viewIdMap['All People'],
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
size: 210,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['person'].fields['email'],
|
||||
viewId: viewIdMap['All People'],
|
||||
position: 1,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['person'].fields['company'],
|
||||
viewId: viewIdMap['All People'],
|
||||
position: 2,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['person'].fields['phone'],
|
||||
viewId: viewIdMap['All People'],
|
||||
position: 3,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['person'].fields['createdAt'],
|
||||
viewId: viewIdMap['All People'],
|
||||
position: 4,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['person'].fields['city'],
|
||||
viewId: viewIdMap['All People'],
|
||||
position: 5,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['person'].fields['jobTitle'],
|
||||
viewId: viewIdMap['All People'],
|
||||
position: 6,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['person'].fields['linkedinLink'],
|
||||
viewId: viewIdMap['All People'],
|
||||
position: 7,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['person'].fields['xLink'],
|
||||
viewId: viewIdMap['All People'],
|
||||
position: 8,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
// Opportunity
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['opportunity'].fields['name'],
|
||||
viewId: viewIdMap['All Opportunities'],
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['opportunity'].fields['amount'],
|
||||
viewId: viewIdMap['All Opportunities'],
|
||||
position: 1,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['opportunity'].fields['closeDate'],
|
||||
viewId: viewIdMap['All Opportunities'],
|
||||
position: 2,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['opportunity'].fields['probability'],
|
||||
viewId: viewIdMap['All Opportunities'],
|
||||
position: 3,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId:
|
||||
objectMetadataMap['opportunity'].fields['pointOfContact'],
|
||||
viewId: viewIdMap['All Opportunities'],
|
||||
position: 4,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['opportunity'].fields['name'],
|
||||
viewId: viewIdMap['By Stage'],
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['opportunity'].fields['amount'],
|
||||
viewId: viewIdMap['By Stage'],
|
||||
position: 1,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['opportunity'].fields['closeDate'],
|
||||
viewId: viewIdMap['By Stage'],
|
||||
position: 2,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId: objectMetadataMap['opportunity'].fields['probability'],
|
||||
viewId: viewIdMap['By Stage'],
|
||||
position: 3,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
{
|
||||
fieldMetadataId:
|
||||
objectMetadataMap['opportunity'].fields['pointOfContact'],
|
||||
viewId: viewIdMap['By Stage'],
|
||||
position: 4,
|
||||
isVisible: true,
|
||||
size: 150,
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
};
|
||||
@ -0,0 +1,44 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
|
||||
import { Command, CommandRunner, Option } from 'nest-commander';
|
||||
|
||||
import { MessageQueue } from 'src/integrations/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/integrations/message-queue/services/message-queue.service';
|
||||
import { CleanInactiveWorkspaceJob } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-inactive-workspace.job';
|
||||
|
||||
export type CleanInactiveWorkspacesCommandOptions = {
|
||||
dryRun: boolean;
|
||||
};
|
||||
|
||||
@Command({
|
||||
name: 'clean-inactive-workspaces',
|
||||
description: 'Clean inactive workspaces',
|
||||
})
|
||||
export class CleanInactiveWorkspacesCommand extends CommandRunner {
|
||||
constructor(
|
||||
@Inject(MessageQueue.taskAssignedQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-d, --dry-run [dry run]',
|
||||
description: 'Dry run: Log cleaning actions without executing them.',
|
||||
required: false,
|
||||
})
|
||||
dryRun(value: string): boolean {
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
async run(
|
||||
_passedParam: string[],
|
||||
options: CleanInactiveWorkspacesCommandOptions,
|
||||
): Promise<void> {
|
||||
await this.messageQueueService.add<CleanInactiveWorkspacesCommandOptions>(
|
||||
CleanInactiveWorkspaceJob.name,
|
||||
options,
|
||||
{ retryLimit: 3 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { Command, CommandRunner, Option } from 'nest-commander';
|
||||
import { FindOptionsWhere, In, Repository } from 'typeorm';
|
||||
|
||||
import { WorkspaceService } from 'src/engine/modules/workspace/services/workspace.service';
|
||||
import { Workspace } from 'src/engine/modules/workspace/workspace.entity';
|
||||
import { getDryRunLogHeader } from 'src/utils/get-dry-run-log-header';
|
||||
import { DataSourceService } from 'src/engine-metadata/data-source/data-source.service';
|
||||
|
||||
type DeleteIncompleteWorkspacesCommandOptions = {
|
||||
dryRun?: boolean;
|
||||
workspaceIds?: string[];
|
||||
};
|
||||
|
||||
@Command({
|
||||
name: 'workspace:delete-incomplete',
|
||||
description: 'Delete incomplete workspaces',
|
||||
})
|
||||
export class DeleteIncompleteWorkspacesCommand extends CommandRunner {
|
||||
private readonly logger = new Logger(DeleteIncompleteWorkspacesCommand.name);
|
||||
constructor(
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
private readonly dataSourceService: DataSourceService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-d, --dry-run [dry run]',
|
||||
description: 'Dry run: Log delete actions without executing them.',
|
||||
required: false,
|
||||
})
|
||||
dryRun(value: string): boolean {
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-w, --workspace-ids [workspace_ids]',
|
||||
description: 'comma separated workspace ids',
|
||||
required: false,
|
||||
})
|
||||
parseWorkspaceIds(value: string): string[] {
|
||||
return value.split(',');
|
||||
}
|
||||
|
||||
async run(
|
||||
_passedParam: string[],
|
||||
options: DeleteIncompleteWorkspacesCommandOptions,
|
||||
): Promise<void> {
|
||||
const where: FindOptionsWhere<Workspace> = {
|
||||
subscriptionStatus: 'incomplete',
|
||||
};
|
||||
|
||||
if (options.workspaceIds) {
|
||||
where.id = In(options.workspaceIds);
|
||||
}
|
||||
|
||||
const incompleteWorkspaces = await this.workspaceRepository.findBy(where);
|
||||
const dataSources =
|
||||
await this.dataSourceService.getManyDataSourceMetadata();
|
||||
const workspaceIdsWithSchema = dataSources.map(
|
||||
(dataSource) => dataSource.workspaceId,
|
||||
);
|
||||
const incompleteWorkspacesToDelete = incompleteWorkspaces.filter(
|
||||
(incompleteWorkspace) =>
|
||||
workspaceIdsWithSchema.includes(incompleteWorkspace.id),
|
||||
);
|
||||
|
||||
if (incompleteWorkspacesToDelete.length) {
|
||||
this.logger.log(
|
||||
`Running Deleting incomplete workspaces on ${incompleteWorkspacesToDelete.length} workspaces`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const incompleteWorkspace of incompleteWorkspacesToDelete) {
|
||||
this.logger.log(
|
||||
`${getDryRunLogHeader(options.dryRun)}Deleting workspace ${
|
||||
incompleteWorkspace.id
|
||||
} name: '${incompleteWorkspace.displayName}'`,
|
||||
);
|
||||
if (!options.dryRun) {
|
||||
await this.workspaceService.deleteWorkspace(
|
||||
incompleteWorkspace.id,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import { MessageQueue } from 'src/integrations/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/integrations/message-queue/services/message-queue.service';
|
||||
import { cleanInactiveWorkspaceCronPattern } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-inactive-workspace.cron.pattern';
|
||||
import { CleanInactiveWorkspaceJob } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-inactive-workspace.job';
|
||||
|
||||
@Command({
|
||||
name: 'clean-inactive-workspaces:cron:start',
|
||||
description: 'Starts a cron job to clean inactive workspaces',
|
||||
})
|
||||
export class StartCleanInactiveWorkspacesCronCommand extends CommandRunner {
|
||||
constructor(
|
||||
@Inject(MessageQueue.cronQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
await this.messageQueueService.addCron<undefined>(
|
||||
CleanInactiveWorkspaceJob.name,
|
||||
undefined,
|
||||
cleanInactiveWorkspaceCronPattern,
|
||||
{ retryLimit: 3 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import { MessageQueue } from 'src/integrations/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/integrations/message-queue/services/message-queue.service';
|
||||
import { cleanInactiveWorkspaceCronPattern } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-inactive-workspace.cron.pattern';
|
||||
import { CleanInactiveWorkspaceJob } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-inactive-workspace.job';
|
||||
|
||||
@Command({
|
||||
name: 'clean-inactive-workspaces:cron:stop',
|
||||
description: 'Stops the clean inactive workspaces cron job',
|
||||
})
|
||||
export class StopCleanInactiveWorkspacesCronCommand extends CommandRunner {
|
||||
constructor(
|
||||
@Inject(MessageQueue.cronQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
await this.messageQueueService.removeCron(
|
||||
CleanInactiveWorkspaceJob.name,
|
||||
cleanInactiveWorkspaceCronPattern,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export const cleanInactiveWorkspaceCronPattern = '0 22 * * *'; // Every day at 10pm
|
||||
@ -0,0 +1,263 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { render } from '@react-email/render';
|
||||
import { In } from 'typeorm';
|
||||
import {
|
||||
CleanInactiveWorkspaceEmail,
|
||||
DeleteInactiveWorkspaceEmail,
|
||||
} from 'twenty-emails';
|
||||
|
||||
import { MessageQueueJob } from 'src/integrations/message-queue/interfaces/message-queue-job.interface';
|
||||
|
||||
import { ObjectMetadataService } from 'src/engine-metadata/object-metadata/object-metadata.service';
|
||||
import { DataSourceService } from 'src/engine-metadata/data-source/data-source.service';
|
||||
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
|
||||
import { DataSourceEntity } from 'src/engine-metadata/data-source/data-source.entity';
|
||||
import { UserService } from 'src/engine/modules/user/services/user.service';
|
||||
import { EmailService } from 'src/integrations/email/email.service';
|
||||
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { computeObjectTargetTable } from 'src/engine-workspace/utils/compute-object-target-table.util';
|
||||
import { CleanInactiveWorkspacesCommandOptions } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-inactive-workspaces.command';
|
||||
import { getDryRunLogHeader } from 'src/utils/get-dry-run-log-header';
|
||||
|
||||
const MILLISECONDS_IN_ONE_DAY = 1000 * 3600 * 24;
|
||||
|
||||
type WorkspaceToDeleteData = {
|
||||
workspaceId: string;
|
||||
daysSinceInactive: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CleanInactiveWorkspaceJob
|
||||
implements MessageQueueJob<CleanInactiveWorkspacesCommandOptions>
|
||||
{
|
||||
private readonly logger = new Logger(CleanInactiveWorkspaceJob.name);
|
||||
private readonly inactiveDaysBeforeDelete;
|
||||
private readonly inactiveDaysBeforeEmail;
|
||||
|
||||
constructor(
|
||||
private readonly dataSourceService: DataSourceService,
|
||||
private readonly objectMetadataService: ObjectMetadataService,
|
||||
private readonly typeORMService: TypeORMService,
|
||||
private readonly userService: UserService,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {
|
||||
this.inactiveDaysBeforeDelete = this.environmentService.get(
|
||||
'WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION',
|
||||
);
|
||||
this.inactiveDaysBeforeEmail = this.environmentService.get(
|
||||
'WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION',
|
||||
);
|
||||
}
|
||||
|
||||
async getMostRecentUpdatedAt(
|
||||
dataSource: DataSourceEntity,
|
||||
objectsMetadata: ObjectMetadataEntity[],
|
||||
): Promise<Date> {
|
||||
const tableNames = objectsMetadata
|
||||
.filter(
|
||||
(objectMetadata) =>
|
||||
objectMetadata.workspaceId === dataSource.workspaceId,
|
||||
)
|
||||
.map((objectMetadata) => computeObjectTargetTable(objectMetadata));
|
||||
|
||||
const workspaceDataSource =
|
||||
await this.typeORMService.connectToDataSource(dataSource);
|
||||
|
||||
let mostRecentUpdatedAtDate = new Date(0);
|
||||
|
||||
for (const tableName of tableNames) {
|
||||
const mostRecentTableUpdatedAt = (
|
||||
await workspaceDataSource?.query(
|
||||
`SELECT MAX("updatedAt") FROM ${dataSource.schema}."${tableName}"`,
|
||||
)
|
||||
)[0].max;
|
||||
|
||||
if (mostRecentTableUpdatedAt) {
|
||||
const mostRecentTableUpdatedAtDate = new Date(mostRecentTableUpdatedAt);
|
||||
|
||||
if (
|
||||
!mostRecentUpdatedAtDate ||
|
||||
mostRecentTableUpdatedAtDate > mostRecentUpdatedAtDate
|
||||
) {
|
||||
mostRecentUpdatedAtDate = mostRecentTableUpdatedAtDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mostRecentUpdatedAtDate;
|
||||
}
|
||||
|
||||
async warnWorkspaceUsers(
|
||||
dataSource: DataSourceEntity,
|
||||
daysSinceInactive: number,
|
||||
isDryRun: boolean,
|
||||
) {
|
||||
const workspaceMembers =
|
||||
await this.userService.loadWorkspaceMembers(dataSource);
|
||||
|
||||
const workspaceDataSource =
|
||||
await this.typeORMService.connectToDataSource(dataSource);
|
||||
|
||||
const displayName = (
|
||||
await workspaceDataSource?.query(
|
||||
`SELECT "displayName" FROM core.workspace WHERE id='${dataSource.workspaceId}'`,
|
||||
)
|
||||
)?.[0].displayName;
|
||||
|
||||
this.logger.log(
|
||||
`${getDryRunLogHeader(isDryRun)}Sending workspace ${
|
||||
dataSource.workspaceId
|
||||
} inactive since ${daysSinceInactive} days emails to users ['${workspaceMembers
|
||||
.map((workspaceUser) => workspaceUser.email)
|
||||
.join(', ')}']`,
|
||||
);
|
||||
|
||||
if (isDryRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
workspaceMembers.forEach((workspaceMember) => {
|
||||
const emailData = {
|
||||
daysLeft: this.inactiveDaysBeforeDelete - daysSinceInactive,
|
||||
userName: `${workspaceMember.nameFirstName} ${workspaceMember.nameLastName}`,
|
||||
workspaceDisplayName: `${displayName}`,
|
||||
};
|
||||
const emailTemplate = CleanInactiveWorkspaceEmail(emailData);
|
||||
const html = render(emailTemplate, {
|
||||
pretty: true,
|
||||
});
|
||||
const text = render(emailTemplate, {
|
||||
plainText: true,
|
||||
});
|
||||
|
||||
this.emailService.send({
|
||||
to: workspaceMember.email,
|
||||
bcc: this.environmentService.get('EMAIL_SYSTEM_ADDRESS'),
|
||||
from: `${this.environmentService.get(
|
||||
'EMAIL_FROM_NAME',
|
||||
)} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
|
||||
subject: 'Action Needed to Prevent Workspace Deletion',
|
||||
html,
|
||||
text,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
chunkArray(array: any[], chunkSize = 6): any[][] {
|
||||
const chunkedArray: any[][] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < array.length) {
|
||||
chunkedArray.push(array.slice(index, index + chunkSize));
|
||||
index += chunkSize;
|
||||
}
|
||||
|
||||
return chunkedArray;
|
||||
}
|
||||
|
||||
async sendDeleteWorkspaceEmail(
|
||||
workspacesToDelete: WorkspaceToDeleteData[],
|
||||
isDryRun: boolean,
|
||||
): Promise<void> {
|
||||
this.logger.log(
|
||||
`${getDryRunLogHeader(
|
||||
isDryRun,
|
||||
)}Sending email to delete workspaces "${workspacesToDelete
|
||||
.map((workspaceToDelete) => workspaceToDelete.workspaceId)
|
||||
.join('", "')}"`,
|
||||
);
|
||||
|
||||
if (isDryRun || workspacesToDelete.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emailTemplate = DeleteInactiveWorkspaceEmail(workspacesToDelete);
|
||||
const html = render(emailTemplate, {
|
||||
pretty: true,
|
||||
});
|
||||
const text = render(emailTemplate, {
|
||||
plainText: true,
|
||||
});
|
||||
|
||||
await this.emailService.send({
|
||||
to: this.environmentService.get('EMAIL_SYSTEM_ADDRESS'),
|
||||
from: `${this.environmentService.get(
|
||||
'EMAIL_FROM_NAME',
|
||||
)} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
|
||||
subject: 'Action Needed to Delete Workspaces',
|
||||
html,
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
async handle(data: CleanInactiveWorkspacesCommandOptions): Promise<void> {
|
||||
const isDryRun = data.dryRun || false;
|
||||
|
||||
const workspacesToDelete: WorkspaceToDeleteData[] = [];
|
||||
|
||||
this.logger.log(`${getDryRunLogHeader(isDryRun)}Job running...`);
|
||||
if (!this.inactiveDaysBeforeDelete && !this.inactiveDaysBeforeEmail) {
|
||||
this.logger.log(
|
||||
`'WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION' and 'WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION' environment variables not set, please check this doc for more info: https://docs.twenty.com/start/self-hosting/environment-variables`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const dataSources =
|
||||
await this.dataSourceService.getManyDataSourceMetadata();
|
||||
|
||||
const dataSourcesChunks = this.chunkArray(dataSources);
|
||||
|
||||
this.logger.log(
|
||||
`${getDryRunLogHeader(isDryRun)}On ${
|
||||
dataSources.length
|
||||
} workspaces divided in ${dataSourcesChunks.length} chunks...`,
|
||||
);
|
||||
|
||||
for (const dataSourcesChunk of dataSourcesChunks) {
|
||||
const objectsMetadata = await this.objectMetadataService.findMany({
|
||||
where: {
|
||||
dataSourceId: In(dataSourcesChunk.map((dataSource) => dataSource.id)),
|
||||
},
|
||||
});
|
||||
|
||||
for (const dataSource of dataSourcesChunk) {
|
||||
this.logger.log(
|
||||
`${getDryRunLogHeader(isDryRun)}Cleaning Workspace ${
|
||||
dataSource.workspaceId
|
||||
}`,
|
||||
);
|
||||
|
||||
const mostRecentUpdatedAt = await this.getMostRecentUpdatedAt(
|
||||
dataSource,
|
||||
objectsMetadata,
|
||||
);
|
||||
const daysSinceInactive = Math.floor(
|
||||
(new Date().getTime() - mostRecentUpdatedAt.getTime()) /
|
||||
MILLISECONDS_IN_ONE_DAY,
|
||||
);
|
||||
|
||||
if (daysSinceInactive > this.inactiveDaysBeforeDelete) {
|
||||
workspacesToDelete.push({
|
||||
daysSinceInactive: daysSinceInactive,
|
||||
workspaceId: `${dataSource.workspaceId}`,
|
||||
});
|
||||
} else if (daysSinceInactive > this.inactiveDaysBeforeEmail) {
|
||||
await this.warnWorkspaceUsers(
|
||||
dataSource,
|
||||
daysSinceInactive,
|
||||
isDryRun,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.sendDeleteWorkspaceEmail(workspacesToDelete, isDryRun);
|
||||
|
||||
this.logger.log(`${getDryRunLogHeader(isDryRun)}job done!`);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkspaceModule } from 'src/engine/modules/workspace/workspace.module';
|
||||
import { DeleteIncompleteWorkspacesCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/delete-incomplete-workspaces.command';
|
||||
import { Workspace } from 'src/engine/modules/workspace/workspace.entity';
|
||||
import { CleanInactiveWorkspacesCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-inactive-workspaces.command';
|
||||
import { StartCleanInactiveWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/start-clean-inactive-workspaces.cron.command';
|
||||
import { StopCleanInactiveWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/stop-clean-inactive-workspaces.cron.command';
|
||||
import { DataSourceModule } from 'src/engine-metadata/data-source/data-source.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Workspace], 'core'),
|
||||
WorkspaceModule,
|
||||
DataSourceModule,
|
||||
],
|
||||
providers: [
|
||||
DeleteIncompleteWorkspacesCommand,
|
||||
CleanInactiveWorkspacesCommand,
|
||||
StartCleanInactiveWorkspacesCronCommand,
|
||||
StopCleanInactiveWorkspacesCronCommand,
|
||||
],
|
||||
})
|
||||
export class WorkspaceCleanerModule {}
|
||||
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceHealthCommand } from 'src/engine/workspace-manager/workspace-health/commands/workspace-health.command';
|
||||
import { WorkspaceHealthModule } from 'src/engine/workspace-manager/workspace-health/workspace-health.module';
|
||||
|
||||
@Module({
|
||||
imports: [WorkspaceHealthModule],
|
||||
providers: [WorkspaceHealthCommand],
|
||||
})
|
||||
export class WorkspaceHealthCommandModule {}
|
||||
@ -0,0 +1,136 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { Command, CommandRunner, Option } from 'nest-commander';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { WorkspaceHealthMode } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-options.interface';
|
||||
import { WorkspaceHealthFixKind } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-fix-kind.interface';
|
||||
|
||||
import { WorkspaceHealthService } from 'src/engine/workspace-manager/workspace-health/workspace-health.service';
|
||||
import { CommandLogger } from 'src/commands/command-logger';
|
||||
|
||||
interface WorkspaceHealthCommandOptions {
|
||||
workspaceId: string;
|
||||
mode?: WorkspaceHealthMode;
|
||||
fix?: WorkspaceHealthFixKind;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
@Command({
|
||||
name: 'workspace:health',
|
||||
description: 'Check health of the given workspace.',
|
||||
})
|
||||
export class WorkspaceHealthCommand extends CommandRunner {
|
||||
private readonly logger = new Logger(WorkspaceHealthCommand.name);
|
||||
private readonly commandLogger = new CommandLogger(
|
||||
WorkspaceHealthCommand.name,
|
||||
);
|
||||
|
||||
constructor(private readonly workspaceHealthService: WorkspaceHealthService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(
|
||||
_passedParam: string[],
|
||||
options: WorkspaceHealthCommandOptions,
|
||||
): Promise<void> {
|
||||
const issues = await this.workspaceHealthService.healthCheck(
|
||||
options.workspaceId,
|
||||
{
|
||||
mode: options.mode ?? WorkspaceHealthMode.All,
|
||||
},
|
||||
);
|
||||
|
||||
if (issues.length === 0) {
|
||||
this.logger.log(chalk.green('Workspace is healthy'));
|
||||
} else {
|
||||
this.logger.log(
|
||||
chalk.red(`Workspace is not healthy, found ${issues.length} issues`),
|
||||
);
|
||||
|
||||
if (options.dryRun) {
|
||||
await this.commandLogger.writeLog(
|
||||
`workspace-health-issues-${options.workspaceId}`,
|
||||
issues,
|
||||
);
|
||||
this.logger.log(chalk.yellow('Issues written to log'));
|
||||
}
|
||||
}
|
||||
|
||||
if (options.fix) {
|
||||
this.logger.log(chalk.yellow('Fixing issues'));
|
||||
|
||||
const { workspaceMigrations, metadataEntities } =
|
||||
await this.workspaceHealthService.fixIssues(
|
||||
options.workspaceId,
|
||||
issues,
|
||||
{
|
||||
type: options.fix,
|
||||
applyChanges: !options.dryRun,
|
||||
},
|
||||
);
|
||||
const totalCount = workspaceMigrations.length + metadataEntities.length;
|
||||
|
||||
if (options.dryRun) {
|
||||
await this.commandLogger.writeLog(
|
||||
`workspace-health-${options.fix}-migrations`,
|
||||
workspaceMigrations,
|
||||
);
|
||||
|
||||
await this.commandLogger.writeLog(
|
||||
`workspace-health-${options.fix}-metadata-entities`,
|
||||
metadataEntities,
|
||||
);
|
||||
} else {
|
||||
this.logger.log(
|
||||
chalk.green(`Fixed ${totalCount}/${issues.length} issues`),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-w, --workspace-id [workspace_id]',
|
||||
description: 'workspace id',
|
||||
required: true,
|
||||
})
|
||||
parseWorkspaceId(value: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-f, --fix [kind]',
|
||||
description: 'fix issues',
|
||||
required: false,
|
||||
})
|
||||
fix(value: string): WorkspaceHealthFixKind {
|
||||
if (!Object.values(WorkspaceHealthFixKind).includes(value as any)) {
|
||||
throw new Error(`Invalid fix kind ${value}`);
|
||||
}
|
||||
|
||||
return value as WorkspaceHealthFixKind;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-m, --mode [mode]',
|
||||
description: 'Mode of the health check [structure, metadata, all]',
|
||||
required: false,
|
||||
defaultValue: WorkspaceHealthMode.All,
|
||||
})
|
||||
parseMode(value: string): WorkspaceHealthMode {
|
||||
if (!Object.values(WorkspaceHealthMode).includes(value as any)) {
|
||||
throw new Error(`Invalid mode ${value}`);
|
||||
}
|
||||
|
||||
return value as WorkspaceHealthMode;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-d, --dry-run',
|
||||
description: 'Dry run without applying changes',
|
||||
required: false,
|
||||
})
|
||||
dryRun(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
import { EntityManager } from 'typeorm';
|
||||
|
||||
import {
|
||||
WorkspaceHealthIssue,
|
||||
WorkspaceHealthIssueType,
|
||||
WorkspaceIssueTypeToInterface,
|
||||
} from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { WorkspaceMigrationEntity } from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
|
||||
|
||||
export class CompareEntity<T> {
|
||||
current: T | null;
|
||||
altered: T | null;
|
||||
}
|
||||
|
||||
export abstract class AbstractWorkspaceFixer<
|
||||
IssueTypes extends WorkspaceHealthIssueType,
|
||||
UpdateRecordEntities = unknown,
|
||||
> {
|
||||
private issueTypes: IssueTypes[];
|
||||
|
||||
constructor(...issueTypes: IssueTypes[]) {
|
||||
this.issueTypes = issueTypes;
|
||||
}
|
||||
|
||||
filterIssues(
|
||||
issues: WorkspaceHealthIssue[],
|
||||
): WorkspaceIssueTypeToInterface<IssueTypes>[] {
|
||||
return issues.filter(
|
||||
(issue): issue is WorkspaceIssueTypeToInterface<IssueTypes> =>
|
||||
this.issueTypes.includes(issue.type as IssueTypes),
|
||||
);
|
||||
}
|
||||
|
||||
protected splitIssuesByType(
|
||||
issues: WorkspaceIssueTypeToInterface<IssueTypes>[],
|
||||
): Record<IssueTypes, WorkspaceIssueTypeToInterface<IssueTypes>[]> {
|
||||
return issues.reduce(
|
||||
(
|
||||
acc: Record<IssueTypes, WorkspaceIssueTypeToInterface<IssueTypes>[]>,
|
||||
issue,
|
||||
) => {
|
||||
const type = issue.type as IssueTypes;
|
||||
|
||||
if (!acc[type]) {
|
||||
acc[type] = [];
|
||||
}
|
||||
acc[type].push(issue);
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<IssueTypes, WorkspaceIssueTypeToInterface<IssueTypes>[]>,
|
||||
);
|
||||
}
|
||||
|
||||
async createWorkspaceMigrations?(
|
||||
manager: EntityManager,
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
issues: WorkspaceIssueTypeToInterface<IssueTypes>[],
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]>;
|
||||
|
||||
async createMetadataUpdates?(
|
||||
manager: EntityManager,
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
issues: WorkspaceIssueTypeToInterface<IssueTypes>[],
|
||||
): Promise<CompareEntity<UpdateRecordEntities>[]>;
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import { WorkspaceNullableFixer } from './workspace-nullable.fixer';
|
||||
import { WorkspaceDefaultValueFixer } from './workspace-default-value.fixer';
|
||||
import { WorkspaceTypeFixer } from './workspace-type.fixer';
|
||||
import { WorkspaceTargetColumnMapFixer } from './workspace-target-column-map.fixer';
|
||||
|
||||
export const workspaceFixers = [
|
||||
WorkspaceNullableFixer,
|
||||
WorkspaceDefaultValueFixer,
|
||||
WorkspaceTypeFixer,
|
||||
WorkspaceTargetColumnMapFixer,
|
||||
];
|
||||
@ -0,0 +1,101 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { EntityManager } from 'typeorm';
|
||||
|
||||
import {
|
||||
WorkspaceHealthColumnIssue,
|
||||
WorkspaceHealthIssueType,
|
||||
} from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
import { WorkspaceMigrationBuilderAction } from 'src/engine/workspace-manager/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface';
|
||||
import { FieldMetadataDefaultValue } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-default-value.interface';
|
||||
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { WorkspaceMigrationEntity } from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
|
||||
import { WorkspaceMigrationFieldFactory } from 'src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-field.factory';
|
||||
|
||||
import { AbstractWorkspaceFixer } from './abstract-workspace.fixer';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceDefaultValueFixer extends AbstractWorkspaceFixer<WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT> {
|
||||
constructor(
|
||||
private readonly workspaceMigrationFieldFactory: WorkspaceMigrationFieldFactory,
|
||||
) {
|
||||
super(WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT);
|
||||
}
|
||||
|
||||
async createWorkspaceMigrations(
|
||||
manager: EntityManager,
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT>[],
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
if (issues.length <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.fixColumnDefaultValueIssues(objectMetadataCollection, issues);
|
||||
}
|
||||
|
||||
private async fixColumnDefaultValueIssues(
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT>[],
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
const fieldMetadataUpdateCollection = issues.map((issue) => {
|
||||
const oldDefaultValue =
|
||||
this.computeFieldMetadataDefaultValueFromColumnDefault(
|
||||
issue.columnStructure?.columnDefault,
|
||||
);
|
||||
|
||||
return {
|
||||
current: {
|
||||
...issue.fieldMetadata,
|
||||
defaultValue: oldDefaultValue,
|
||||
},
|
||||
altered: issue.fieldMetadata,
|
||||
};
|
||||
});
|
||||
|
||||
return this.workspaceMigrationFieldFactory.create(
|
||||
objectMetadataCollection,
|
||||
fieldMetadataUpdateCollection,
|
||||
WorkspaceMigrationBuilderAction.UPDATE,
|
||||
);
|
||||
}
|
||||
|
||||
private computeFieldMetadataDefaultValueFromColumnDefault(
|
||||
columnDefault: string | undefined,
|
||||
): FieldMetadataDefaultValue<'default'> {
|
||||
if (
|
||||
columnDefault === undefined ||
|
||||
columnDefault === null ||
|
||||
columnDefault === 'NULL'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isNaN(Number(columnDefault))) {
|
||||
return { value: +columnDefault };
|
||||
}
|
||||
|
||||
if (columnDefault === 'true') {
|
||||
return { value: true };
|
||||
}
|
||||
|
||||
if (columnDefault === 'false') {
|
||||
return { value: false };
|
||||
}
|
||||
|
||||
if (columnDefault === '') {
|
||||
return { value: '' };
|
||||
}
|
||||
|
||||
if (columnDefault === 'now()') {
|
||||
return { type: 'now' };
|
||||
}
|
||||
|
||||
if (columnDefault.startsWith('public.uuid_generate_v4')) {
|
||||
return { type: 'uuid' };
|
||||
}
|
||||
|
||||
return { value: columnDefault };
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { EntityManager } from 'typeorm';
|
||||
|
||||
import {
|
||||
WorkspaceHealthColumnIssue,
|
||||
WorkspaceHealthIssueType,
|
||||
} from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
import { WorkspaceMigrationBuilderAction } from 'src/engine/workspace-manager/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface';
|
||||
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { WorkspaceMigrationEntity } from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
|
||||
import { WorkspaceMigrationFieldFactory } from 'src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-field.factory';
|
||||
|
||||
import { AbstractWorkspaceFixer } from './abstract-workspace.fixer';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceNullableFixer extends AbstractWorkspaceFixer<WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT> {
|
||||
constructor(
|
||||
private readonly workspaceMigrationFieldFactory: WorkspaceMigrationFieldFactory,
|
||||
) {
|
||||
super(WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT);
|
||||
}
|
||||
|
||||
async createWorkspaceMigrations(
|
||||
manager: EntityManager,
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT>[],
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
if (issues.length <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.fixColumnNullabilityIssues(objectMetadataCollection, issues);
|
||||
}
|
||||
|
||||
private async fixColumnNullabilityIssues(
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT>[],
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
const fieldMetadataUpdateCollection = issues.map((issue) => {
|
||||
return {
|
||||
current: {
|
||||
...issue.fieldMetadata,
|
||||
isNullable: issue.columnStructure?.isNullable ?? false,
|
||||
},
|
||||
altered: issue.fieldMetadata,
|
||||
};
|
||||
});
|
||||
|
||||
return this.workspaceMigrationFieldFactory.create(
|
||||
objectMetadataCollection,
|
||||
fieldMetadataUpdateCollection,
|
||||
WorkspaceMigrationBuilderAction.UPDATE,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,174 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { EntityManager } from 'typeorm';
|
||||
import isEqual from 'lodash.isequal';
|
||||
|
||||
import {
|
||||
WorkspaceHealthColumnIssue,
|
||||
WorkspaceHealthIssueType,
|
||||
} from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
import { WorkspaceMigrationBuilderAction } from 'src/engine/workspace-manager/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface';
|
||||
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { generateTargetColumnMap } from 'src/engine-metadata/field-metadata/utils/generate-target-column-map.util';
|
||||
import { FieldMetadataEntity } from 'src/engine-metadata/field-metadata/field-metadata.entity';
|
||||
import { WorkspaceMigrationEntity } from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
|
||||
import { computeObjectTargetTable } from 'src/engine-workspace/utils/compute-object-target-table.util';
|
||||
import { DataSourceEntity } from 'src/engine-metadata/data-source/data-source.entity';
|
||||
import { DatabaseStructureService } from 'src/engine/workspace-manager/workspace-health/services/database-structure.service';
|
||||
import { WorkspaceMigrationFieldFactory } from 'src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-field.factory';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine-metadata/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
|
||||
import {
|
||||
AbstractWorkspaceFixer,
|
||||
CompareEntity,
|
||||
} from './abstract-workspace.fixer';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceTargetColumnMapFixer extends AbstractWorkspaceFixer<
|
||||
WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID,
|
||||
FieldMetadataEntity
|
||||
> {
|
||||
constructor(
|
||||
private readonly workspaceMigrationFieldFactory: WorkspaceMigrationFieldFactory,
|
||||
private readonly databaseStructureService: DatabaseStructureService,
|
||||
) {
|
||||
super(WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID);
|
||||
}
|
||||
|
||||
async createWorkspaceMigrations(
|
||||
manager: EntityManager,
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID>[],
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
if (issues.length <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.fixStructureTargetColumnMapIssues(
|
||||
manager,
|
||||
objectMetadataCollection,
|
||||
issues,
|
||||
);
|
||||
}
|
||||
|
||||
async createMetadataUpdates(
|
||||
manager: EntityManager,
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID>[],
|
||||
): Promise<CompareEntity<FieldMetadataEntity>[]> {
|
||||
if (issues.length <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.fixMetadataTargetColumnMapIssues(manager, issues);
|
||||
}
|
||||
|
||||
private async fixStructureTargetColumnMapIssues(
|
||||
manager: EntityManager,
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID>[],
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
const workspaceMigrationCollection: Partial<WorkspaceMigrationEntity>[] =
|
||||
[];
|
||||
const dataSourceRepository = manager.getRepository(DataSourceEntity);
|
||||
|
||||
for (const issue of issues) {
|
||||
const objectMetadata = objectMetadataCollection.find(
|
||||
(metadata) => metadata.id === issue.fieldMetadata.objectMetadataId,
|
||||
);
|
||||
const targetColumnMap = generateTargetColumnMap(
|
||||
issue.fieldMetadata.type,
|
||||
issue.fieldMetadata.isCustom,
|
||||
issue.fieldMetadata.name,
|
||||
);
|
||||
|
||||
// Skip composite fields, too complicated to fix for now
|
||||
if (isCompositeFieldMetadataType(issue.fieldMetadata.type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!objectMetadata) {
|
||||
throw new Error(
|
||||
`Object metadata with id ${issue.fieldMetadata.objectMetadataId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isEqual(issue.fieldMetadata.targetColumnMap, targetColumnMap)) {
|
||||
// Retrieve the data source to get the schema name
|
||||
const dataSource = await dataSourceRepository.findOne({
|
||||
where: {
|
||||
id: objectMetadata.dataSourceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dataSource) {
|
||||
throw new Error(
|
||||
`Data source with id ${objectMetadata.dataSourceId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
const columnName = issue.fieldMetadata.targetColumnMap?.value;
|
||||
const columnExist =
|
||||
await this.databaseStructureService.workspaceColumnExist(
|
||||
dataSource.schema,
|
||||
computeObjectTargetTable(objectMetadata),
|
||||
columnName,
|
||||
);
|
||||
|
||||
if (!columnExist) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const workspaceMigration =
|
||||
await this.workspaceMigrationFieldFactory.create(
|
||||
objectMetadataCollection,
|
||||
[
|
||||
{
|
||||
current: issue.fieldMetadata,
|
||||
altered: {
|
||||
...issue.fieldMetadata,
|
||||
targetColumnMap,
|
||||
},
|
||||
},
|
||||
],
|
||||
WorkspaceMigrationBuilderAction.UPDATE,
|
||||
);
|
||||
|
||||
workspaceMigrationCollection.push(workspaceMigration[0]);
|
||||
}
|
||||
}
|
||||
|
||||
return workspaceMigrationCollection;
|
||||
}
|
||||
|
||||
private async fixMetadataTargetColumnMapIssues(
|
||||
manager: EntityManager,
|
||||
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID>[],
|
||||
): Promise<CompareEntity<FieldMetadataEntity>[]> {
|
||||
const fieldMetadataRepository = manager.getRepository(FieldMetadataEntity);
|
||||
const updatedEntities: CompareEntity<FieldMetadataEntity>[] = [];
|
||||
|
||||
for (const issue of issues) {
|
||||
await fieldMetadataRepository.update(issue.fieldMetadata.id, {
|
||||
targetColumnMap: generateTargetColumnMap(
|
||||
issue.fieldMetadata.type,
|
||||
issue.fieldMetadata.isCustom,
|
||||
issue.fieldMetadata.name,
|
||||
),
|
||||
});
|
||||
const alteredEntity = await fieldMetadataRepository.findOne({
|
||||
where: {
|
||||
id: issue.fieldMetadata.id,
|
||||
},
|
||||
});
|
||||
|
||||
updatedEntities.push({
|
||||
current: issue.fieldMetadata,
|
||||
altered: alteredEntity as FieldMetadataEntity | null,
|
||||
});
|
||||
}
|
||||
|
||||
return updatedEntities;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { EntityManager } from 'typeorm';
|
||||
|
||||
import {
|
||||
WorkspaceHealthColumnIssue,
|
||||
WorkspaceHealthIssueType,
|
||||
} from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
import { WorkspaceMigrationBuilderAction } from 'src/engine/workspace-manager/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface';
|
||||
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { WorkspaceMigrationEntity } from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
|
||||
import {
|
||||
FieldMetadataUpdate,
|
||||
WorkspaceMigrationFieldFactory,
|
||||
} from 'src/engine/workspace-manager/workspace-migration-builder/factories/workspace-migration-field.factory';
|
||||
import { DatabaseStructureService } from 'src/engine/workspace-manager/workspace-health/services/database-structure.service';
|
||||
|
||||
import { AbstractWorkspaceFixer } from './abstract-workspace.fixer';
|
||||
|
||||
const oldDataTypes = ['integer'];
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceTypeFixer extends AbstractWorkspaceFixer<WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT> {
|
||||
private readonly logger = new Logger(WorkspaceTypeFixer.name);
|
||||
|
||||
constructor(
|
||||
private readonly workspaceMigrationFieldFactory: WorkspaceMigrationFieldFactory,
|
||||
private readonly databaseStructureService: DatabaseStructureService,
|
||||
) {
|
||||
super(WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT);
|
||||
}
|
||||
|
||||
async createWorkspaceMigrations(
|
||||
manager: EntityManager,
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT>[],
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
if (issues.length <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.fixColumnTypeIssues(objectMetadataCollection, issues);
|
||||
}
|
||||
|
||||
private async fixColumnTypeIssues(
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
issues: WorkspaceHealthColumnIssue<WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT>[],
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
const fieldMetadataUpdateCollection: FieldMetadataUpdate[] = [];
|
||||
|
||||
for (const issue of issues) {
|
||||
const dataType = issue.columnStructure?.dataType;
|
||||
|
||||
if (!dataType) {
|
||||
throw new Error('Column structure data type is missing');
|
||||
}
|
||||
|
||||
const type =
|
||||
this.databaseStructureService.getFieldMetadataTypeFromPostgresDataType(
|
||||
dataType,
|
||||
);
|
||||
|
||||
if (oldDataTypes.includes(dataType)) {
|
||||
this.logger.warn(
|
||||
`Old data type detected for column ${issue.columnStructure?.columnName} with data type ${dataType}. Please update the column data type manually.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!type) {
|
||||
throw new Error("Can't find field metadata type from column structure");
|
||||
}
|
||||
|
||||
fieldMetadataUpdateCollection.push({
|
||||
current: {
|
||||
...issue.fieldMetadata,
|
||||
type,
|
||||
},
|
||||
altered: issue.fieldMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
return this.workspaceMigrationFieldFactory.create(
|
||||
objectMetadataCollection,
|
||||
fieldMetadataUpdateCollection,
|
||||
WorkspaceMigrationBuilderAction.UPDATE,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
export enum WorkspaceHealthFixKind {
|
||||
Nullable = 'nullable',
|
||||
Type = 'type',
|
||||
DefaultValue = 'default-value',
|
||||
TargetColumnMap = 'target-column-map',
|
||||
}
|
||||
@ -0,0 +1,119 @@
|
||||
import { WorkspaceTableStructure } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-table-definition.interface';
|
||||
|
||||
import { FieldMetadataEntity } from 'src/engine-metadata/field-metadata/field-metadata.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { RelationMetadataEntity } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
|
||||
|
||||
export enum WorkspaceHealthIssueType {
|
||||
MISSING_TABLE = 'MISSING_TABLE',
|
||||
TABLE_NAME_SHOULD_BE_CUSTOM = 'TABLE_NAME_SHOULD_BE_CUSTOM',
|
||||
TABLE_TARGET_TABLE_NAME_NOT_VALID = 'TABLE_TARGET_TABLE_NAME_NOT_VALID',
|
||||
TABLE_DATA_SOURCE_ID_NOT_VALID = 'TABLE_DATA_SOURCE_ID_NOT_VALID',
|
||||
TABLE_NAME_NOT_VALID = 'TABLE_NAME_NOT_VALID',
|
||||
MISSING_COLUMN = 'MISSING_COLUMN',
|
||||
MISSING_INDEX = 'MISSING_INDEX',
|
||||
MISSING_FOREIGN_KEY = 'MISSING_FOREIGN_KEY',
|
||||
MISSING_COMPOSITE_TYPE = 'MISSING_COMPOSITE_TYPE',
|
||||
COLUMN_NAME_SHOULD_NOT_BE_PREFIXED = 'COLUMN_NAME_SHOULD_NOT_BE_PREFIXED',
|
||||
COLUMN_TARGET_COLUMN_MAP_NOT_VALID = 'COLUMN_TARGET_COLUMN_MAP_NOT_VALID',
|
||||
COLUMN_NAME_SHOULD_BE_CUSTOM = 'COLUMN_NAME_SHOULD_BE_CUSTOM',
|
||||
COLUMN_OBJECT_REFERENCE_INVALID = 'COLUMN_OBJECT_REFERENCE_INVALID',
|
||||
COLUMN_NAME_NOT_VALID = 'COLUMN_NAME_NOT_VALID',
|
||||
COLUMN_TYPE_NOT_VALID = 'COLUMN_TYPE_NOT_VALID',
|
||||
COLUMN_DATA_TYPE_CONFLICT = 'COLUMN_DATA_TYPE_CONFLICT',
|
||||
COLUMN_NULLABILITY_CONFLICT = 'COLUMN_NULLABILITY_CONFLICT',
|
||||
COLUMN_DEFAULT_VALUE_CONFLICT = 'COLUMN_DEFAULT_VALUE_CONFLICT',
|
||||
COLUMN_DEFAULT_VALUE_NOT_VALID = 'COLUMN_DEFAULT_VALUE_NOT_VALID',
|
||||
COLUMN_OPTIONS_NOT_VALID = 'COLUMN_OPTIONS_NOT_VALID',
|
||||
RELATION_METADATA_NOT_VALID = 'RELATION_METADATA_NOT_VALID',
|
||||
RELATION_FROM_OR_TO_FIELD_METADATA_NOT_VALID = 'RELATION_FROM_OR_TO_FIELD_METADATA_NOT_VALID',
|
||||
RELATION_FOREIGN_KEY_NOT_VALID = 'RELATION_FOREIGN_KEY_NOT_VALID',
|
||||
RELATION_FOREIGN_KEY_CONFLICT = 'RELATION_FOREIGN_KEY_CONFLICT',
|
||||
RELATION_FOREIGN_KEY_ON_DELETE_ACTION_CONFLICT = 'RELATION_FOREIGN_KEY_ON_DELETE_ACTION_CONFLICT',
|
||||
RELATION_TYPE_NOT_VALID = 'RELATION_TYPE_NOT_VALID',
|
||||
}
|
||||
|
||||
/**
|
||||
* Table issues
|
||||
*/
|
||||
export type WorkspaceTableIssueTypes =
|
||||
| WorkspaceHealthIssueType.MISSING_TABLE
|
||||
| WorkspaceHealthIssueType.TABLE_NAME_SHOULD_BE_CUSTOM
|
||||
| WorkspaceHealthIssueType.TABLE_TARGET_TABLE_NAME_NOT_VALID
|
||||
| WorkspaceHealthIssueType.TABLE_DATA_SOURCE_ID_NOT_VALID
|
||||
| WorkspaceHealthIssueType.TABLE_NAME_NOT_VALID;
|
||||
|
||||
export interface WorkspaceHealthTableIssue<T extends WorkspaceTableIssueTypes> {
|
||||
type: T;
|
||||
objectMetadata: ObjectMetadataEntity;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Column issues
|
||||
*/
|
||||
export type WorkspaceColumnIssueTypes =
|
||||
| WorkspaceHealthIssueType.MISSING_COLUMN
|
||||
| WorkspaceHealthIssueType.MISSING_INDEX
|
||||
| WorkspaceHealthIssueType.MISSING_FOREIGN_KEY
|
||||
| WorkspaceHealthIssueType.MISSING_COMPOSITE_TYPE
|
||||
| WorkspaceHealthIssueType.COLUMN_NAME_SHOULD_NOT_BE_PREFIXED
|
||||
| WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID
|
||||
| WorkspaceHealthIssueType.COLUMN_NAME_SHOULD_BE_CUSTOM
|
||||
| WorkspaceHealthIssueType.COLUMN_OBJECT_REFERENCE_INVALID
|
||||
| WorkspaceHealthIssueType.COLUMN_NAME_NOT_VALID
|
||||
| WorkspaceHealthIssueType.COLUMN_TYPE_NOT_VALID
|
||||
| WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT
|
||||
| WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT
|
||||
| WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT
|
||||
| WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_NOT_VALID
|
||||
| WorkspaceHealthIssueType.COLUMN_OPTIONS_NOT_VALID;
|
||||
|
||||
export interface WorkspaceHealthColumnIssue<
|
||||
T extends WorkspaceColumnIssueTypes,
|
||||
> {
|
||||
type: T;
|
||||
fieldMetadata: FieldMetadataEntity;
|
||||
columnStructure?: WorkspaceTableStructure;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relation issues
|
||||
*/
|
||||
export type WorkspaceRelationIssueTypes =
|
||||
| WorkspaceHealthIssueType.RELATION_METADATA_NOT_VALID
|
||||
| WorkspaceHealthIssueType.RELATION_FROM_OR_TO_FIELD_METADATA_NOT_VALID
|
||||
| WorkspaceHealthIssueType.RELATION_FOREIGN_KEY_NOT_VALID
|
||||
| WorkspaceHealthIssueType.RELATION_FOREIGN_KEY_CONFLICT
|
||||
| WorkspaceHealthIssueType.RELATION_FOREIGN_KEY_ON_DELETE_ACTION_CONFLICT
|
||||
| WorkspaceHealthIssueType.RELATION_TYPE_NOT_VALID;
|
||||
|
||||
export interface WorkspaceHealthRelationIssue<
|
||||
T extends WorkspaceRelationIssueTypes,
|
||||
> {
|
||||
type: T;
|
||||
fromFieldMetadata?: FieldMetadataEntity | undefined;
|
||||
toFieldMetadata?: FieldMetadataEntity | undefined;
|
||||
relationMetadata?: RelationMetadataEntity;
|
||||
columnStructure?: WorkspaceTableStructure;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the interface for the issue type
|
||||
*/
|
||||
export type WorkspaceIssueTypeToInterface<T extends WorkspaceHealthIssueType> =
|
||||
T extends WorkspaceTableIssueTypes
|
||||
? WorkspaceHealthTableIssue<T>
|
||||
: T extends WorkspaceColumnIssueTypes
|
||||
? WorkspaceHealthColumnIssue<T>
|
||||
: T extends WorkspaceRelationIssueTypes
|
||||
? WorkspaceHealthRelationIssue<T>
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Union of all issues
|
||||
*/
|
||||
export type WorkspaceHealthIssue =
|
||||
WorkspaceIssueTypeToInterface<WorkspaceHealthIssueType>;
|
||||
@ -0,0 +1,9 @@
|
||||
export enum WorkspaceHealthMode {
|
||||
Structure = 'structure',
|
||||
Metadata = 'metadata',
|
||||
All = 'all',
|
||||
}
|
||||
|
||||
export interface WorkspaceHealthOptions {
|
||||
mode: WorkspaceHealthMode;
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
export interface WorkspaceTableStructure {
|
||||
tableSchema: string;
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
dataType: string;
|
||||
columnDefault: string;
|
||||
isNullable: boolean;
|
||||
isPrimaryKey: boolean;
|
||||
isForeignKey: boolean;
|
||||
isUnique: boolean;
|
||||
onUpdateAction: string;
|
||||
onDeleteAction: string;
|
||||
}
|
||||
|
||||
export type WorkspaceTableStructureResult = {
|
||||
[P in keyof WorkspaceTableStructure]: string;
|
||||
};
|
||||
@ -0,0 +1,239 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { ColumnType } from 'typeorm';
|
||||
import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata';
|
||||
|
||||
import {
|
||||
WorkspaceTableStructure,
|
||||
WorkspaceTableStructureResult,
|
||||
} from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-table-definition.interface';
|
||||
import { FieldMetadataDefaultValue } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-default-value.interface';
|
||||
|
||||
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
|
||||
import {
|
||||
FieldMetadataEntity,
|
||||
FieldMetadataType,
|
||||
} from 'src/engine-metadata/field-metadata/field-metadata.entity';
|
||||
import { fieldMetadataTypeToColumnType } from 'src/engine-metadata/workspace-migration/utils/field-metadata-type-to-column-type.util';
|
||||
import { serializeTypeDefaultValue } from 'src/engine-metadata/field-metadata/utils/serialize-type-default-value.util';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine-metadata/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { isRelationFieldMetadataType } from 'src/engine-workspace/utils/is-relation-field-metadata-type.util';
|
||||
|
||||
@Injectable()
|
||||
export class DatabaseStructureService {
|
||||
constructor(private readonly typeORMService: TypeORMService) {}
|
||||
|
||||
async getWorkspaceTableColumns(
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
): Promise<WorkspaceTableStructure[]> {
|
||||
const mainDataSource = this.typeORMService.getMainDataSource();
|
||||
const results = await mainDataSource.query<
|
||||
WorkspaceTableStructureResult[]
|
||||
>(`
|
||||
WITH foreign_keys AS (
|
||||
SELECT
|
||||
kcu.table_schema AS schema_name,
|
||||
kcu.table_name AS table_name,
|
||||
kcu.column_name AS column_name,
|
||||
tc.constraint_name AS constraint_name
|
||||
FROM
|
||||
information_schema.key_column_usage AS kcu
|
||||
JOIN
|
||||
information_schema.table_constraints AS tc
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_schema = kcu.table_schema
|
||||
WHERE
|
||||
tc.constraint_type = 'FOREIGN KEY'
|
||||
AND tc.table_schema = '${schemaName}'
|
||||
AND tc.table_name = '${tableName}'
|
||||
),
|
||||
unique_constraints AS (
|
||||
SELECT
|
||||
tc.table_schema AS schema_name,
|
||||
tc.table_name AS table_name,
|
||||
kcu.column_name AS column_name
|
||||
FROM
|
||||
information_schema.key_column_usage AS kcu
|
||||
JOIN
|
||||
information_schema.table_constraints AS tc
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_schema = kcu.table_schema
|
||||
WHERE
|
||||
tc.constraint_type = 'UNIQUE'
|
||||
AND tc.table_schema = '${schemaName}'
|
||||
AND tc.table_name = '${tableName}'
|
||||
)
|
||||
SELECT
|
||||
c.table_schema AS "tableSchema",
|
||||
c.table_name AS "tableName",
|
||||
c.column_name AS "columnName",
|
||||
CASE
|
||||
WHEN (c.data_type = 'USER-DEFINED') THEN c.udt_name
|
||||
ELSE data_type
|
||||
END AS "dataType",
|
||||
c.is_nullable AS "isNullable",
|
||||
c.column_default AS "columnDefault",
|
||||
CASE
|
||||
WHEN pk.constraint_type = 'PRIMARY KEY' THEN 'TRUE'
|
||||
ELSE 'FALSE'
|
||||
END AS "isPrimaryKey",
|
||||
CASE
|
||||
WHEN fk.constraint_name IS NOT NULL THEN 'TRUE'
|
||||
ELSE 'FALSE'
|
||||
END AS "isForeignKey",
|
||||
CASE
|
||||
WHEN uc.column_name IS NOT NULL THEN 'TRUE'
|
||||
ELSE 'FALSE'
|
||||
END AS "isUnique",
|
||||
rc.update_rule AS "onUpdateAction",
|
||||
rc.delete_rule AS "onDeleteAction"
|
||||
FROM
|
||||
information_schema.columns AS c
|
||||
LEFT JOIN
|
||||
information_schema.constraint_column_usage AS ccu
|
||||
ON c.column_name = ccu.column_name
|
||||
AND c.table_name = ccu.table_name
|
||||
AND c.table_schema = ccu.table_schema
|
||||
LEFT JOIN
|
||||
information_schema.table_constraints AS pk
|
||||
ON pk.constraint_name = ccu.constraint_name
|
||||
AND pk.constraint_type = 'PRIMARY KEY'
|
||||
AND pk.table_name = c.table_name
|
||||
AND pk.table_schema = c.table_schema
|
||||
LEFT JOIN
|
||||
foreign_keys AS fk
|
||||
ON c.table_schema = fk.schema_name
|
||||
AND c.table_name = fk.table_name
|
||||
AND c.column_name = fk.column_name
|
||||
LEFT JOIN
|
||||
unique_constraints AS uc
|
||||
ON c.table_schema = uc.schema_name
|
||||
AND c.table_name = uc.table_name
|
||||
AND c.column_name = uc.column_name
|
||||
LEFT JOIN
|
||||
information_schema.referential_constraints AS rc
|
||||
ON rc.constraint_name = fk.constraint_name
|
||||
AND rc.constraint_schema = '${schemaName}'
|
||||
WHERE
|
||||
c.table_schema = '${schemaName}'
|
||||
AND c.table_name = '${tableName}';
|
||||
`);
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return results.map((item) => ({
|
||||
...item,
|
||||
isNullable: item.isNullable === 'YES',
|
||||
isPrimaryKey: item.isPrimaryKey === 'TRUE',
|
||||
isForeignKey: item.isForeignKey === 'TRUE',
|
||||
isUnique: item.isUnique === 'TRUE',
|
||||
}));
|
||||
}
|
||||
|
||||
async workspaceColumnExist(
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
): Promise<boolean> {
|
||||
const mainDataSource = this.typeORMService.getMainDataSource();
|
||||
const results = await mainDataSource.query(
|
||||
`SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = $1
|
||||
AND table_name = $2
|
||||
AND column_name = $3`,
|
||||
[schemaName, tableName, columnName],
|
||||
);
|
||||
|
||||
return results.length >= 1;
|
||||
}
|
||||
|
||||
getPostgresDataType(fieldMetadata: FieldMetadataEntity): string {
|
||||
const typeORMType = fieldMetadataTypeToColumnType(fieldMetadata.type);
|
||||
const mainDataSource = this.typeORMService.getMainDataSource();
|
||||
|
||||
// Compute enum name to compare data type properly
|
||||
if (typeORMType === 'enum') {
|
||||
const objectName = fieldMetadata.object?.nameSingular;
|
||||
const prefix = fieldMetadata.isCustom ? '_' : '';
|
||||
const fieldName = fieldMetadata.name;
|
||||
|
||||
return `${objectName}_${prefix}${fieldName}_enum`;
|
||||
}
|
||||
|
||||
return mainDataSource.driver.normalizeType({
|
||||
type: typeORMType,
|
||||
});
|
||||
}
|
||||
|
||||
getFieldMetadataTypeFromPostgresDataType(
|
||||
postgresDataType: string,
|
||||
): FieldMetadataType | null {
|
||||
const mainDataSource = this.typeORMService.getMainDataSource();
|
||||
const types = Object.values(FieldMetadataType).filter((type) => {
|
||||
// We're skipping composite and relation types, as they're not directly mapped to a column type
|
||||
if (isCompositeFieldMetadataType(type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isRelationFieldMetadataType(type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
for (const type of types) {
|
||||
const typeORMType = fieldMetadataTypeToColumnType(
|
||||
FieldMetadataType[type],
|
||||
) as ColumnType;
|
||||
const dataType = mainDataSource.driver.normalizeType({
|
||||
type: typeORMType,
|
||||
});
|
||||
|
||||
if (postgresDataType === dataType) {
|
||||
return FieldMetadataType[type];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getPostgresDefault(
|
||||
fieldMetadataType: FieldMetadataType,
|
||||
defaultValue: FieldMetadataDefaultValue | null,
|
||||
): string | null | undefined {
|
||||
const typeORMType = fieldMetadataTypeToColumnType(
|
||||
fieldMetadataType,
|
||||
) as ColumnType;
|
||||
const mainDataSource = this.typeORMService.getMainDataSource();
|
||||
|
||||
if (defaultValue && 'type' in defaultValue) {
|
||||
const serializedDefaultValue = serializeTypeDefaultValue(defaultValue);
|
||||
|
||||
// Special case for uuid_generate_v4() default value
|
||||
if (serializedDefaultValue === 'public.uuid_generate_v4()') {
|
||||
return 'uuid_generate_v4()';
|
||||
}
|
||||
|
||||
return serializedDefaultValue;
|
||||
}
|
||||
|
||||
const value =
|
||||
defaultValue && 'value' in defaultValue ? defaultValue.value : null;
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
return mainDataSource.driver.normalizeDefault({
|
||||
type: typeORMType,
|
||||
default: value,
|
||||
isArray: false,
|
||||
// Workaround to use normalizeDefault without a complete ColumnMetadata object
|
||||
} as ColumnMetadata);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,369 @@
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
|
||||
import isEqual from 'lodash.isequal';
|
||||
|
||||
import {
|
||||
WorkspaceHealthIssue,
|
||||
WorkspaceHealthIssueType,
|
||||
} from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
import { WorkspaceTableStructure } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-table-definition.interface';
|
||||
import { WorkspaceHealthOptions } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-options.interface';
|
||||
import { FieldMetadataDefaultValue } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-default-value.interface';
|
||||
|
||||
import {
|
||||
FieldMetadataEntity,
|
||||
FieldMetadataType,
|
||||
} from 'src/engine-metadata/field-metadata/field-metadata.entity';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine-metadata/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { DatabaseStructureService } from 'src/engine/workspace-manager/workspace-health/services/database-structure.service';
|
||||
import { validName } from 'src/engine/workspace-manager/workspace-health/utils/valid-name.util';
|
||||
import { compositeDefinitions } from 'src/engine-metadata/field-metadata/composite-types';
|
||||
import { validateDefaultValueForType } from 'src/engine-metadata/field-metadata/utils/validate-default-value-for-type.util';
|
||||
import {
|
||||
EnumFieldMetadataUnionType,
|
||||
isEnumFieldMetadataType,
|
||||
} from 'src/engine-metadata/field-metadata/utils/is-enum-field-metadata-type.util';
|
||||
import { validateOptionsForType } from 'src/engine-metadata/field-metadata/utils/validate-options-for-type.util';
|
||||
import { serializeDefaultValue } from 'src/engine-metadata/field-metadata/utils/serialize-default-value';
|
||||
import { computeCompositeFieldMetadata } from 'src/engine/workspace-manager/workspace-health/utils/compute-composite-field-metadata.util';
|
||||
import { generateTargetColumnMap } from 'src/engine-metadata/field-metadata/utils/generate-target-column-map.util';
|
||||
import { customNamePrefix } from 'src/engine-workspace/utils/compute-custom-name.util';
|
||||
import { isRelationFieldMetadataType } from 'src/engine-workspace/utils/is-relation-field-metadata-type.util';
|
||||
|
||||
@Injectable()
|
||||
export class FieldMetadataHealthService {
|
||||
constructor(
|
||||
private readonly databaseStructureService: DatabaseStructureService,
|
||||
) {}
|
||||
|
||||
async healthCheck(
|
||||
tableName: string,
|
||||
workspaceTableColumns: WorkspaceTableStructure[],
|
||||
fieldMetadataCollection: FieldMetadataEntity[],
|
||||
options: WorkspaceHealthOptions,
|
||||
): Promise<WorkspaceHealthIssue[]> {
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
|
||||
for (const fieldMetadata of fieldMetadataCollection) {
|
||||
// Relation metadata are checked in another service
|
||||
if (isRelationFieldMetadataType(fieldMetadata.type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isCompositeFieldMetadataType(fieldMetadata.type)) {
|
||||
const compositeFieldMetadataCollection =
|
||||
compositeDefinitions.get(fieldMetadata.type)?.(fieldMetadata) ?? [];
|
||||
|
||||
if (options.mode === 'metadata' || options.mode === 'all') {
|
||||
const targetColumnMapIssue = this.targetColumnMapCheck(fieldMetadata);
|
||||
|
||||
if (targetColumnMapIssue) {
|
||||
issues.push(targetColumnMapIssue);
|
||||
}
|
||||
|
||||
const defaultValueIssues =
|
||||
this.defaultValueHealthCheck(fieldMetadata);
|
||||
|
||||
issues.push(...defaultValueIssues);
|
||||
}
|
||||
|
||||
for (const compositeFieldMetadata of compositeFieldMetadataCollection) {
|
||||
const compositeFieldIssues = await this.healthCheckField(
|
||||
tableName,
|
||||
workspaceTableColumns,
|
||||
computeCompositeFieldMetadata(
|
||||
compositeFieldMetadata,
|
||||
fieldMetadata,
|
||||
),
|
||||
options,
|
||||
);
|
||||
|
||||
issues.push(...compositeFieldIssues);
|
||||
}
|
||||
} else {
|
||||
const fieldIssues = await this.healthCheckField(
|
||||
tableName,
|
||||
workspaceTableColumns,
|
||||
fieldMetadata,
|
||||
options,
|
||||
);
|
||||
|
||||
issues.push(...fieldIssues);
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private async healthCheckField(
|
||||
tableName: string,
|
||||
workspaceTableColumns: WorkspaceTableStructure[],
|
||||
fieldMetadata: FieldMetadataEntity,
|
||||
options: WorkspaceHealthOptions,
|
||||
): Promise<WorkspaceHealthIssue[]> {
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
|
||||
if (options.mode === 'structure' || options.mode === 'all') {
|
||||
const structureIssues = this.structureFieldCheck(
|
||||
tableName,
|
||||
workspaceTableColumns,
|
||||
fieldMetadata,
|
||||
);
|
||||
|
||||
issues.push(...structureIssues);
|
||||
}
|
||||
|
||||
if (options.mode === 'metadata' || options.mode === 'all') {
|
||||
const metadataIssues = this.metadataFieldCheck(tableName, fieldMetadata);
|
||||
|
||||
issues.push(...metadataIssues);
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private structureFieldCheck(
|
||||
tableName: string,
|
||||
workspaceTableColumns: WorkspaceTableStructure[],
|
||||
fieldMetadata: FieldMetadataEntity,
|
||||
): WorkspaceHealthIssue[] {
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
const columnName = fieldMetadata.targetColumnMap.value;
|
||||
|
||||
const dataType =
|
||||
this.databaseStructureService.getPostgresDataType(fieldMetadata);
|
||||
|
||||
const defaultValue = this.databaseStructureService.getPostgresDefault(
|
||||
fieldMetadata.type,
|
||||
fieldMetadata.defaultValue,
|
||||
);
|
||||
// Check if column exist in database
|
||||
const columnStructure = workspaceTableColumns.find(
|
||||
(tableDefinition) => tableDefinition.columnName === columnName,
|
||||
);
|
||||
|
||||
if (!columnStructure) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.MISSING_COLUMN,
|
||||
fieldMetadata,
|
||||
columnStructure,
|
||||
message: `Column ${columnName} not found in table ${tableName}`,
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
const columnDefaultValue = columnStructure.columnDefault?.split('::')?.[0];
|
||||
|
||||
// Check if column data type is the same
|
||||
if (columnStructure.dataType !== dataType) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT,
|
||||
fieldMetadata,
|
||||
columnStructure,
|
||||
message: `Column ${columnName} type is not the same as the field metadata type "${columnStructure.dataType}" !== "${dataType}"`,
|
||||
});
|
||||
}
|
||||
|
||||
if (columnStructure.isNullable !== fieldMetadata.isNullable) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT,
|
||||
fieldMetadata,
|
||||
columnStructure,
|
||||
message: `Column ${columnName} is expected to be ${
|
||||
fieldMetadata.isNullable ? 'nullable' : 'not nullable'
|
||||
} but is ${columnStructure.isNullable ? 'nullable' : 'not nullable'}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (columnDefaultValue && isEnumFieldMetadataType(fieldMetadata.type)) {
|
||||
const enumValues = fieldMetadata.options?.map((option) =>
|
||||
serializeDefaultValue(option.value),
|
||||
);
|
||||
|
||||
if (!enumValues.includes(columnDefaultValue)) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_NOT_VALID,
|
||||
fieldMetadata,
|
||||
columnStructure,
|
||||
message: `Column ${columnName} default value is not in the enum values "${columnDefaultValue}" NOT IN "${enumValues}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (columnDefaultValue !== defaultValue) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT,
|
||||
fieldMetadata,
|
||||
columnStructure,
|
||||
message: `Column ${columnName} default value is not the same as the field metadata default value "${columnStructure.columnDefault}" !== "${defaultValue}"`,
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private metadataFieldCheck(
|
||||
tableName: string,
|
||||
fieldMetadata: FieldMetadataEntity,
|
||||
): WorkspaceHealthIssue[] {
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
const columnName = fieldMetadata.targetColumnMap.value;
|
||||
const targetColumnMapIssue = this.targetColumnMapCheck(fieldMetadata);
|
||||
const defaultValueIssues = this.defaultValueHealthCheck(fieldMetadata);
|
||||
|
||||
if (targetColumnMapIssue) {
|
||||
issues.push(targetColumnMapIssue);
|
||||
}
|
||||
|
||||
if (fieldMetadata.name.startsWith(customNamePrefix)) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_NAME_SHOULD_NOT_BE_PREFIXED,
|
||||
fieldMetadata,
|
||||
message: `Column ${columnName} should not be prefixed with "${customNamePrefix}"`,
|
||||
});
|
||||
}
|
||||
|
||||
if (fieldMetadata.isCustom && !columnName?.startsWith(customNamePrefix)) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_NAME_SHOULD_BE_CUSTOM,
|
||||
fieldMetadata,
|
||||
message: `Column ${columnName} is marked as custom in table ${tableName} but doesn't start with "_"`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!fieldMetadata.objectMetadataId) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_OBJECT_REFERENCE_INVALID,
|
||||
fieldMetadata,
|
||||
message: `Column ${columnName} doesn't have a valid object metadata id`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!Object.values(FieldMetadataType).includes(fieldMetadata.type)) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_TYPE_NOT_VALID,
|
||||
fieldMetadata,
|
||||
message: `Column ${columnName} doesn't have a valid field metadata type`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!fieldMetadata.name ||
|
||||
!validName(fieldMetadata.name) ||
|
||||
!fieldMetadata.label
|
||||
) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_NAME_NOT_VALID,
|
||||
fieldMetadata,
|
||||
message: `Column ${columnName} doesn't have a valid name or label`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isEnumFieldMetadataType(fieldMetadata.type) &&
|
||||
!validateOptionsForType(fieldMetadata.type, fieldMetadata.options)
|
||||
) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_OPTIONS_NOT_VALID,
|
||||
fieldMetadata,
|
||||
message: `Column options of ${fieldMetadata.targetColumnMap?.value} is not valid`,
|
||||
});
|
||||
}
|
||||
|
||||
issues.push(...defaultValueIssues);
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private targetColumnMapCheck(
|
||||
fieldMetadata: FieldMetadataEntity,
|
||||
): WorkspaceHealthIssue | null {
|
||||
const targetColumnMap = generateTargetColumnMap(
|
||||
fieldMetadata.type,
|
||||
fieldMetadata.isCustom,
|
||||
fieldMetadata.name,
|
||||
);
|
||||
|
||||
if (
|
||||
!fieldMetadata.targetColumnMap ||
|
||||
!isEqual(targetColumnMap, fieldMetadata.targetColumnMap)
|
||||
) {
|
||||
return {
|
||||
type: WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID,
|
||||
fieldMetadata,
|
||||
message: `Column targetColumnMap "${JSON.stringify(
|
||||
fieldMetadata.targetColumnMap,
|
||||
)}" is not the same as the generated one "${JSON.stringify(
|
||||
targetColumnMap,
|
||||
)}"`,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private defaultValueHealthCheck(
|
||||
fieldMetadata: FieldMetadataEntity,
|
||||
): WorkspaceHealthIssue[] {
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
|
||||
if (
|
||||
!validateDefaultValueForType(
|
||||
fieldMetadata.type,
|
||||
fieldMetadata.defaultValue,
|
||||
)
|
||||
) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_NOT_VALID,
|
||||
fieldMetadata,
|
||||
message: `Column default value for composite type ${fieldMetadata.type} is not well structured`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isEnumFieldMetadataType(fieldMetadata.type) &&
|
||||
fieldMetadata.defaultValue
|
||||
) {
|
||||
const enumValues = fieldMetadata.options?.map((option) => option.value);
|
||||
const metadataDefaultValue = (
|
||||
fieldMetadata.defaultValue as FieldMetadataDefaultValue<EnumFieldMetadataUnionType>
|
||||
)?.value;
|
||||
|
||||
if (metadataDefaultValue && !enumValues.includes(metadataDefaultValue)) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_NOT_VALID,
|
||||
fieldMetadata,
|
||||
message: `Column default value is not in the enum values "${metadataDefaultValue}" NOT IN "${enumValues}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private isCompositeObjectWellStructured(
|
||||
fieldMetadataType: FieldMetadataType,
|
||||
object: any,
|
||||
): boolean {
|
||||
const subFields = compositeDefinitions.get(fieldMetadataType)?.() ?? [];
|
||||
|
||||
if (!object) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (subFields.length === 0) {
|
||||
throw new InternalServerErrorException(
|
||||
`The composite field type ${fieldMetadataType} doesn't have any sub fields, it seems this one is not implemented in the composite definitions map`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const subField of subFields) {
|
||||
if (!object[subField.name]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,116 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
WorkspaceHealthIssue,
|
||||
WorkspaceHealthIssueType,
|
||||
} from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
import { WorkspaceHealthOptions } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-options.interface';
|
||||
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { validName } from 'src/engine/workspace-manager/workspace-health/utils/valid-name.util';
|
||||
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
|
||||
import { computeObjectTargetTable } from 'src/engine-workspace/utils/compute-object-target-table.util';
|
||||
|
||||
@Injectable()
|
||||
export class ObjectMetadataHealthService {
|
||||
constructor(private readonly typeORMService: TypeORMService) {}
|
||||
|
||||
async healthCheck(
|
||||
schemaName: string,
|
||||
objectMetadata: ObjectMetadataEntity,
|
||||
options: WorkspaceHealthOptions,
|
||||
): Promise<WorkspaceHealthIssue[]> {
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
|
||||
if (options.mode === 'structure' || options.mode === 'all') {
|
||||
const structureIssues = await this.structureObjectCheck(
|
||||
schemaName,
|
||||
objectMetadata,
|
||||
);
|
||||
|
||||
issues.push(...structureIssues);
|
||||
}
|
||||
|
||||
if (options.mode === 'metadata' || options.mode === 'all') {
|
||||
const metadataIssues = this.metadataObjectCheck(objectMetadata);
|
||||
|
||||
issues.push(...metadataIssues);
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the structure health of the table based on metadata
|
||||
* @param schemaName
|
||||
* @param objectMetadata
|
||||
* @returns WorkspaceHealthIssue[]
|
||||
*/
|
||||
private async structureObjectCheck(
|
||||
schemaName: string,
|
||||
objectMetadata: ObjectMetadataEntity,
|
||||
): Promise<WorkspaceHealthIssue[]> {
|
||||
const mainDataSource = this.typeORMService.getMainDataSource();
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
|
||||
// Check if the table exist in database
|
||||
const tableExist = await mainDataSource.query(
|
||||
`SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = '${schemaName}'
|
||||
AND table_name = '${computeObjectTargetTable(objectMetadata)}')`,
|
||||
);
|
||||
|
||||
if (!tableExist) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.MISSING_TABLE,
|
||||
objectMetadata,
|
||||
message: `Table ${computeObjectTargetTable(
|
||||
objectMetadata,
|
||||
)} not found in schema ${schemaName}`,
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check ObjectMetadata health
|
||||
* @param objectMetadata
|
||||
* @returns WorkspaceHealthIssue[]
|
||||
*/
|
||||
private metadataObjectCheck(
|
||||
objectMetadata: ObjectMetadataEntity,
|
||||
): WorkspaceHealthIssue[] {
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
|
||||
if (!objectMetadata.dataSourceId) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.TABLE_DATA_SOURCE_ID_NOT_VALID,
|
||||
objectMetadata,
|
||||
message: `Table ${computeObjectTargetTable(
|
||||
objectMetadata,
|
||||
)} doesn't have a data source`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!objectMetadata.nameSingular ||
|
||||
!objectMetadata.namePlural ||
|
||||
!validName(objectMetadata.nameSingular) ||
|
||||
!validName(objectMetadata.namePlural) ||
|
||||
!objectMetadata.labelSingular ||
|
||||
!objectMetadata.labelPlural
|
||||
) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.TABLE_NAME_NOT_VALID,
|
||||
objectMetadata,
|
||||
message: `Table ${computeObjectTargetTable(
|
||||
objectMetadata,
|
||||
)} doesn't have a valid name or label`,
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,254 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceTableStructure } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-table-definition.interface';
|
||||
import {
|
||||
WorkspaceHealthIssue,
|
||||
WorkspaceHealthIssueType,
|
||||
} from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
import {
|
||||
WorkspaceHealthMode,
|
||||
WorkspaceHealthOptions,
|
||||
} from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-options.interface';
|
||||
|
||||
import { FieldMetadataEntity } from 'src/engine-metadata/field-metadata/field-metadata.entity';
|
||||
import {
|
||||
RelationMetadataEntity,
|
||||
RelationMetadataType,
|
||||
} from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
|
||||
import {
|
||||
RelationDirection,
|
||||
deduceRelationDirection,
|
||||
} from 'src/engine-workspace/utils/deduce-relation-direction.util';
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { createRelationForeignKeyColumnName } from 'src/engine-metadata/relation-metadata/utils/create-relation-foreign-key-column-name.util';
|
||||
import { createRelationForeignKeyFieldMetadataName } from 'src/engine-metadata/relation-metadata/utils/create-relation-foreign-key-field-metadata-name.util';
|
||||
import { isRelationFieldMetadataType } from 'src/engine-workspace/utils/is-relation-field-metadata-type.util';
|
||||
import { convertOnDeleteActionToOnDelete } from 'src/engine/workspace-manager/workspace-migration-runner/utils/convert-on-delete-action-to-on-delete.util';
|
||||
|
||||
@Injectable()
|
||||
export class RelationMetadataHealthService {
|
||||
constructor() {}
|
||||
|
||||
public healthCheck(
|
||||
workspaceTableColumns: WorkspaceTableStructure[],
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
objectMetadata: ObjectMetadataEntity,
|
||||
options: WorkspaceHealthOptions,
|
||||
) {
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
|
||||
for (const fieldMetadata of objectMetadata.fields) {
|
||||
// We're only interested in relation fields
|
||||
if (!isRelationFieldMetadataType(fieldMetadata.type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const relationMetadata =
|
||||
fieldMetadata.fromRelationMetadata ?? fieldMetadata.toRelationMetadata;
|
||||
|
||||
if (!relationMetadata) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.RELATION_METADATA_NOT_VALID,
|
||||
message: `Field ${fieldMetadata.id} has invalid relation metadata`,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const relationDirection = deduceRelationDirection(
|
||||
fieldMetadata,
|
||||
relationMetadata,
|
||||
);
|
||||
|
||||
// Many to many relations are not supported yet
|
||||
if (relationMetadata.relationType === RelationMetadataType.MANY_TO_MANY) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fromObjectMetadata = objectMetadataCollection.find(
|
||||
(objectMetadata) =>
|
||||
objectMetadata.id === relationMetadata.fromObjectMetadataId,
|
||||
);
|
||||
const fromFieldMetadata = fromObjectMetadata?.fields.find(
|
||||
(fieldMetadata) =>
|
||||
fieldMetadata.id === relationMetadata.fromFieldMetadataId,
|
||||
);
|
||||
const toObjectMetadata = objectMetadataCollection.find(
|
||||
(objectMetadata) =>
|
||||
objectMetadata.id === relationMetadata.toObjectMetadataId,
|
||||
);
|
||||
const toFieldMetadata = toObjectMetadata?.fields.find(
|
||||
(fieldMetadata) =>
|
||||
fieldMetadata.id === relationMetadata.toFieldMetadataId,
|
||||
);
|
||||
|
||||
if (!fromFieldMetadata || !toFieldMetadata) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.RELATION_FROM_OR_TO_FIELD_METADATA_NOT_VALID,
|
||||
fromFieldMetadata,
|
||||
toFieldMetadata,
|
||||
relationMetadata,
|
||||
message: `Relation ${relationMetadata.id} has invalid from or to field metadata`,
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
if (
|
||||
options.mode === WorkspaceHealthMode.All ||
|
||||
options.mode === WorkspaceHealthMode.Structure
|
||||
) {
|
||||
// Check relation structure
|
||||
const structureIssues = this.structureRelationCheck(
|
||||
fromFieldMetadata,
|
||||
toFieldMetadata,
|
||||
toObjectMetadata?.fields ?? [],
|
||||
relationDirection,
|
||||
relationMetadata,
|
||||
workspaceTableColumns,
|
||||
);
|
||||
|
||||
issues.push(...structureIssues);
|
||||
}
|
||||
|
||||
if (
|
||||
options.mode === WorkspaceHealthMode.All ||
|
||||
options.mode === WorkspaceHealthMode.Metadata
|
||||
) {
|
||||
// Check relation metadata
|
||||
const metadataIssues = this.metadataRelationCheck(
|
||||
fromFieldMetadata,
|
||||
toFieldMetadata,
|
||||
relationDirection,
|
||||
relationMetadata,
|
||||
);
|
||||
|
||||
issues.push(...metadataIssues);
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private structureRelationCheck(
|
||||
fromFieldMetadata: FieldMetadataEntity,
|
||||
toFieldMetadata: FieldMetadataEntity,
|
||||
toObjectMetadataFields: FieldMetadataEntity[],
|
||||
relationDirection: RelationDirection,
|
||||
relationMetadata: RelationMetadataEntity,
|
||||
workspaceTableColumns: WorkspaceTableStructure[],
|
||||
): WorkspaceHealthIssue[] {
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
|
||||
// Nothing to check on the structure
|
||||
if (relationDirection === RelationDirection.FROM) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const isCustom = toFieldMetadata.isCustom ?? false;
|
||||
const foreignKeyColumnName = createRelationForeignKeyColumnName(
|
||||
toFieldMetadata.name,
|
||||
isCustom,
|
||||
);
|
||||
const relationColumn = workspaceTableColumns.find(
|
||||
(column) => column.columnName === foreignKeyColumnName,
|
||||
);
|
||||
const relationFieldMetadata = toObjectMetadataFields.find(
|
||||
(fieldMetadata) =>
|
||||
fieldMetadata.name ===
|
||||
createRelationForeignKeyFieldMetadataName(toFieldMetadata.name),
|
||||
);
|
||||
|
||||
if (!relationFieldMetadata) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.RELATION_FOREIGN_KEY_NOT_VALID,
|
||||
fromFieldMetadata,
|
||||
toFieldMetadata,
|
||||
relationMetadata,
|
||||
message: `Relation ${
|
||||
relationMetadata.id
|
||||
} doesn't have a valid foreign key (expected fieldMetadata.name to be ${createRelationForeignKeyFieldMetadataName(
|
||||
toFieldMetadata.name,
|
||||
)}`,
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
if (!relationColumn) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.RELATION_FOREIGN_KEY_NOT_VALID,
|
||||
fromFieldMetadata,
|
||||
toFieldMetadata,
|
||||
relationMetadata,
|
||||
message: `Relation ${relationMetadata.id} doesn't have a valid foreign key (expected column name to be ${foreignKeyColumnName}`,
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
if (!relationColumn.isForeignKey) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.RELATION_FOREIGN_KEY_CONFLICT,
|
||||
fromFieldMetadata,
|
||||
toFieldMetadata,
|
||||
relationMetadata,
|
||||
message: `Relation ${relationMetadata.id} foreign key is not properly set`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
relationMetadata.relationType === RelationMetadataType.ONE_TO_ONE &&
|
||||
!relationColumn.isUnique
|
||||
) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.RELATION_FOREIGN_KEY_CONFLICT,
|
||||
fromFieldMetadata,
|
||||
toFieldMetadata,
|
||||
relationMetadata,
|
||||
message: `Relation ${relationMetadata.id} foreign key is not marked as unique and relation type is one-to-one`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
convertOnDeleteActionToOnDelete(relationMetadata.onDeleteAction) !==
|
||||
relationColumn.onDeleteAction
|
||||
) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.RELATION_FOREIGN_KEY_ON_DELETE_ACTION_CONFLICT,
|
||||
fromFieldMetadata,
|
||||
toFieldMetadata,
|
||||
relationMetadata,
|
||||
columnStructure: relationColumn,
|
||||
message: `Relation ${relationMetadata.id} foreign key onDeleteAction is not properly set`,
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private metadataRelationCheck(
|
||||
fromFieldMetadata: FieldMetadataEntity,
|
||||
toFieldMetadata: FieldMetadataEntity,
|
||||
relationDirection: RelationDirection,
|
||||
relationMetadata: RelationMetadataEntity,
|
||||
): WorkspaceHealthIssue[] {
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
|
||||
if (
|
||||
!Object.values(RelationMetadataType).includes(
|
||||
relationMetadata.relationType,
|
||||
)
|
||||
) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.RELATION_TYPE_NOT_VALID,
|
||||
fromFieldMetadata,
|
||||
toFieldMetadata,
|
||||
relationMetadata,
|
||||
message: `Relation ${relationMetadata.id} has invalid relation type`,
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { EntityManager } from 'typeorm';
|
||||
|
||||
import { WorkspaceHealthFixKind } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-fix-kind.interface';
|
||||
import { WorkspaceHealthIssue } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
|
||||
import { WorkspaceMigrationEntity } from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { WorkspaceNullableFixer } from 'src/engine/workspace-manager/workspace-health/fixer/workspace-nullable.fixer';
|
||||
import { WorkspaceDefaultValueFixer } from 'src/engine/workspace-manager/workspace-health/fixer/workspace-default-value.fixer';
|
||||
import { WorkspaceTypeFixer } from 'src/engine/workspace-manager/workspace-health/fixer/workspace-type.fixer';
|
||||
import { WorkspaceTargetColumnMapFixer } from 'src/engine/workspace-manager/workspace-health/fixer/workspace-target-column-map.fixer';
|
||||
import { CompareEntity } from 'src/engine/workspace-manager/workspace-health/fixer/abstract-workspace.fixer';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceFixService {
|
||||
constructor(
|
||||
private readonly workspaceNullableFixer: WorkspaceNullableFixer,
|
||||
private readonly workspaceDefaultValueFixer: WorkspaceDefaultValueFixer,
|
||||
private readonly workspaceTypeFixer: WorkspaceTypeFixer,
|
||||
private readonly workspaceTargetColumnMapFixer: WorkspaceTargetColumnMapFixer,
|
||||
) {}
|
||||
|
||||
async createWorkspaceMigrations(
|
||||
manager: EntityManager,
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
type: WorkspaceHealthFixKind,
|
||||
issues: WorkspaceHealthIssue[],
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
switch (type) {
|
||||
case WorkspaceHealthFixKind.Nullable: {
|
||||
const filteredIssues = this.workspaceNullableFixer.filterIssues(issues);
|
||||
|
||||
return this.workspaceNullableFixer.createWorkspaceMigrations(
|
||||
manager,
|
||||
objectMetadataCollection,
|
||||
filteredIssues,
|
||||
);
|
||||
}
|
||||
case WorkspaceHealthFixKind.DefaultValue: {
|
||||
const filteredIssues =
|
||||
this.workspaceDefaultValueFixer.filterIssues(issues);
|
||||
|
||||
return this.workspaceDefaultValueFixer.createWorkspaceMigrations(
|
||||
manager,
|
||||
objectMetadataCollection,
|
||||
filteredIssues,
|
||||
);
|
||||
}
|
||||
case WorkspaceHealthFixKind.Type: {
|
||||
const filteredIssues = this.workspaceTypeFixer.filterIssues(issues);
|
||||
|
||||
return this.workspaceTypeFixer.createWorkspaceMigrations(
|
||||
manager,
|
||||
objectMetadataCollection,
|
||||
filteredIssues,
|
||||
);
|
||||
}
|
||||
case WorkspaceHealthFixKind.TargetColumnMap: {
|
||||
const filteredIssues =
|
||||
this.workspaceTargetColumnMapFixer.filterIssues(issues);
|
||||
|
||||
return this.workspaceTargetColumnMapFixer.createWorkspaceMigrations(
|
||||
manager,
|
||||
objectMetadataCollection,
|
||||
filteredIssues,
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async createMetadataUpdates(
|
||||
manager: EntityManager,
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
type: WorkspaceHealthFixKind,
|
||||
issues: WorkspaceHealthIssue[],
|
||||
): Promise<CompareEntity<unknown>[]> {
|
||||
switch (type) {
|
||||
case WorkspaceHealthFixKind.TargetColumnMap: {
|
||||
const filteredIssues =
|
||||
this.workspaceTargetColumnMapFixer.filterIssues(issues);
|
||||
|
||||
return this.workspaceTargetColumnMapFixer.createMetadataUpdates(
|
||||
manager,
|
||||
objectMetadataCollection,
|
||||
filteredIssues,
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
|
||||
|
||||
import { FieldMetadataEntity } from 'src/engine-metadata/field-metadata/field-metadata.entity';
|
||||
import { camelCase } from 'src/utils/camel-case';
|
||||
|
||||
// Compute composite field metadata by combining the composite field metadata with the field metadata
|
||||
export const computeCompositeFieldMetadata = (
|
||||
compositeFieldMetadata: FieldMetadataInterface,
|
||||
fieldMetadata: FieldMetadataEntity,
|
||||
): FieldMetadataEntity => ({
|
||||
...fieldMetadata,
|
||||
...compositeFieldMetadata,
|
||||
objectMetadataId: fieldMetadata.objectMetadataId,
|
||||
name: camelCase(`${fieldMetadata.name}-${compositeFieldMetadata.name}`),
|
||||
});
|
||||
@ -0,0 +1,25 @@
|
||||
import { WorkspaceHealthIssueType } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
|
||||
export const isWorkspaceHealthNullableIssue = (
|
||||
type: WorkspaceHealthIssueType,
|
||||
): type is WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT => {
|
||||
return type === WorkspaceHealthIssueType.COLUMN_NULLABILITY_CONFLICT;
|
||||
};
|
||||
|
||||
export const isWorkspaceHealthTypeIssue = (
|
||||
type: WorkspaceHealthIssueType,
|
||||
): type is WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT => {
|
||||
return type === WorkspaceHealthIssueType.COLUMN_DATA_TYPE_CONFLICT;
|
||||
};
|
||||
|
||||
export const isWorkspaceHealthDefaultValueIssue = (
|
||||
type: WorkspaceHealthIssueType,
|
||||
): type is WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT => {
|
||||
return type === WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_CONFLICT;
|
||||
};
|
||||
|
||||
export const isWorkspaceHealthTargetColumnMapIssue = (
|
||||
type: WorkspaceHealthIssueType,
|
||||
): type is WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID => {
|
||||
return type === WorkspaceHealthIssueType.COLUMN_TARGET_COLUMN_MAP_NOT_VALID;
|
||||
};
|
||||
@ -0,0 +1,34 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
|
||||
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
|
||||
|
||||
export const mapFieldMetadataTypeToDataType = (
|
||||
fieldMetadataType: FieldMetadataType,
|
||||
): string => {
|
||||
switch (fieldMetadataType) {
|
||||
case FieldMetadataType.UUID:
|
||||
return 'uuid';
|
||||
case FieldMetadataType.TEXT:
|
||||
return 'text';
|
||||
case FieldMetadataType.PHONE:
|
||||
case FieldMetadataType.EMAIL:
|
||||
return 'varchar';
|
||||
case FieldMetadataType.NUMERIC:
|
||||
return 'numeric';
|
||||
case FieldMetadataType.NUMBER:
|
||||
case FieldMetadataType.PROBABILITY:
|
||||
return 'double precision';
|
||||
case FieldMetadataType.BOOLEAN:
|
||||
return 'boolean';
|
||||
case FieldMetadataType.DATE_TIME:
|
||||
return 'timestamp';
|
||||
case FieldMetadataType.RATING:
|
||||
case FieldMetadataType.SELECT:
|
||||
case FieldMetadataType.MULTI_SELECT:
|
||||
return 'enum';
|
||||
default:
|
||||
throw new ConflictException(
|
||||
`Cannot convert ${fieldMetadataType} to data type.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,3 @@
|
||||
export const validName = (name: string): boolean => {
|
||||
return /^[a-zA-Z0-9_]+$/.test(name);
|
||||
};
|
||||
@ -0,0 +1,39 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { DataSourceModule } from 'src/engine-metadata/data-source/data-source.module';
|
||||
import { ObjectMetadataModule } from 'src/engine-metadata/object-metadata/object-metadata.module';
|
||||
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
|
||||
import { DatabaseStructureService } from 'src/engine/workspace-manager/workspace-health/services/database-structure.service';
|
||||
import { FieldMetadataHealthService } from 'src/engine/workspace-manager/workspace-health/services/field-metadata-health.service';
|
||||
import { ObjectMetadataHealthService } from 'src/engine/workspace-manager/workspace-health/services/object-metadata-health.service';
|
||||
import { RelationMetadataHealthService } from 'src/engine/workspace-manager/workspace-health/services/relation-metadata.health.service';
|
||||
import { WorkspaceHealthService } from 'src/engine/workspace-manager/workspace-health/workspace-health.service';
|
||||
import { WorkspaceMigrationBuilderModule } from 'src/engine/workspace-manager/workspace-migration-builder/workspace-migration-builder.module';
|
||||
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module';
|
||||
|
||||
import { workspaceFixers } from './fixer';
|
||||
|
||||
import { WorkspaceFixService } from './services/workspace-fix.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
DataSourceModule,
|
||||
TypeORMModule,
|
||||
ObjectMetadataModule,
|
||||
WorkspaceDataSourceModule,
|
||||
WorkspaceMigrationRunnerModule,
|
||||
WorkspaceMigrationBuilderModule,
|
||||
],
|
||||
providers: [
|
||||
...workspaceFixers,
|
||||
WorkspaceHealthService,
|
||||
DatabaseStructureService,
|
||||
ObjectMetadataHealthService,
|
||||
FieldMetadataHealthService,
|
||||
RelationMetadataHealthService,
|
||||
WorkspaceFixService,
|
||||
],
|
||||
exports: [WorkspaceHealthService],
|
||||
})
|
||||
export class WorkspaceHealthModule {}
|
||||
@ -0,0 +1,201 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { WorkspaceHealthIssue } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-issue.interface';
|
||||
import {
|
||||
WorkspaceHealthMode,
|
||||
WorkspaceHealthOptions,
|
||||
} from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-options.interface';
|
||||
import { WorkspaceHealthFixKind } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-fix-kind.interface';
|
||||
|
||||
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
|
||||
import { DataSourceService } from 'src/engine-metadata/data-source/data-source.service';
|
||||
import { ObjectMetadataService } from 'src/engine-metadata/object-metadata/object-metadata.service';
|
||||
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
|
||||
import { ObjectMetadataHealthService } from 'src/engine/workspace-manager/workspace-health/services/object-metadata-health.service';
|
||||
import { FieldMetadataHealthService } from 'src/engine/workspace-manager/workspace-health/services/field-metadata-health.service';
|
||||
import { RelationMetadataHealthService } from 'src/engine/workspace-manager/workspace-health/services/relation-metadata.health.service';
|
||||
import { DatabaseStructureService } from 'src/engine/workspace-manager/workspace-health/services/database-structure.service';
|
||||
import { computeObjectTargetTable } from 'src/engine-workspace/utils/compute-object-target-table.util';
|
||||
import { WorkspaceMigrationEntity } from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
|
||||
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
|
||||
import { WorkspaceFixService } from 'src/engine/workspace-manager/workspace-health/services/workspace-fix.service';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceHealthService {
|
||||
constructor(
|
||||
@InjectDataSource('metadata')
|
||||
private readonly metadataDataSource: DataSource,
|
||||
private readonly dataSourceService: DataSourceService,
|
||||
private readonly typeORMService: TypeORMService,
|
||||
private readonly objectMetadataService: ObjectMetadataService,
|
||||
private readonly databaseStructureService: DatabaseStructureService,
|
||||
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
|
||||
private readonly objectMetadataHealthService: ObjectMetadataHealthService,
|
||||
private readonly fieldMetadataHealthService: FieldMetadataHealthService,
|
||||
private readonly relationMetadataHealthService: RelationMetadataHealthService,
|
||||
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
|
||||
private readonly workspaceFixService: WorkspaceFixService,
|
||||
) {}
|
||||
|
||||
async healthCheck(
|
||||
workspaceId: string,
|
||||
options: WorkspaceHealthOptions = { mode: WorkspaceHealthMode.All },
|
||||
): Promise<WorkspaceHealthIssue[]> {
|
||||
const schemaName =
|
||||
this.workspaceDataSourceService.getSchemaName(workspaceId);
|
||||
const issues: WorkspaceHealthIssue[] = [];
|
||||
|
||||
const dataSourceMetadata =
|
||||
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
// Check if a data source exists for this workspace
|
||||
if (!dataSourceMetadata) {
|
||||
throw new NotFoundException(
|
||||
`DataSource for workspace id ${workspaceId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
// Try to connect to the data source
|
||||
await this.typeORMService.connectToDataSource(dataSourceMetadata);
|
||||
|
||||
const objectMetadataCollection =
|
||||
await this.objectMetadataService.findManyWithinWorkspace(workspaceId);
|
||||
|
||||
// Check if object metadata exists for this workspace
|
||||
if (!objectMetadataCollection || objectMetadataCollection.length === 0) {
|
||||
throw new NotFoundException(`Workspace with id ${workspaceId} not found`);
|
||||
}
|
||||
|
||||
for (const objectMetadata of objectMetadataCollection) {
|
||||
const tableName = computeObjectTargetTable(objectMetadata);
|
||||
const workspaceTableColumns =
|
||||
await this.databaseStructureService.getWorkspaceTableColumns(
|
||||
schemaName,
|
||||
tableName,
|
||||
);
|
||||
|
||||
if (!workspaceTableColumns || workspaceTableColumns.length === 0) {
|
||||
throw new NotFoundException(
|
||||
`Table ${tableName} not found in schema ${schemaName}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check object metadata health
|
||||
const objectIssues = await this.objectMetadataHealthService.healthCheck(
|
||||
schemaName,
|
||||
objectMetadata,
|
||||
options,
|
||||
);
|
||||
|
||||
issues.push(...objectIssues);
|
||||
|
||||
// Check fields metadata health
|
||||
const fieldIssues = await this.fieldMetadataHealthService.healthCheck(
|
||||
computeObjectTargetTable(objectMetadata),
|
||||
workspaceTableColumns,
|
||||
objectMetadata.fields,
|
||||
options,
|
||||
);
|
||||
|
||||
issues.push(...fieldIssues);
|
||||
|
||||
// Check relation metadata health
|
||||
const relationIssues = this.relationMetadataHealthService.healthCheck(
|
||||
workspaceTableColumns,
|
||||
objectMetadataCollection,
|
||||
objectMetadata,
|
||||
options,
|
||||
);
|
||||
|
||||
issues.push(...relationIssues);
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
async fixIssues(
|
||||
workspaceId: string,
|
||||
issues: WorkspaceHealthIssue[],
|
||||
options: {
|
||||
type: WorkspaceHealthFixKind;
|
||||
applyChanges?: boolean;
|
||||
},
|
||||
): Promise<{
|
||||
workspaceMigrations: Partial<WorkspaceMigrationEntity>[];
|
||||
metadataEntities: unknown[];
|
||||
}> {
|
||||
let workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
|
||||
let metadataEntities: unknown[] = [];
|
||||
|
||||
// Set default options
|
||||
options.applyChanges ??= true;
|
||||
|
||||
const queryRunner = this.metadataDataSource.createQueryRunner();
|
||||
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
const manager = queryRunner.manager;
|
||||
|
||||
try {
|
||||
const workspaceMigrationRepository = manager.getRepository(
|
||||
WorkspaceMigrationEntity,
|
||||
);
|
||||
const objectMetadataCollection =
|
||||
await this.objectMetadataService.findManyWithinWorkspace(workspaceId);
|
||||
|
||||
workspaceMigrations =
|
||||
await this.workspaceFixService.createWorkspaceMigrations(
|
||||
manager,
|
||||
objectMetadataCollection,
|
||||
options.type,
|
||||
issues,
|
||||
);
|
||||
|
||||
metadataEntities = await this.workspaceFixService.createMetadataUpdates(
|
||||
manager,
|
||||
objectMetadataCollection,
|
||||
options.type,
|
||||
issues,
|
||||
);
|
||||
|
||||
// Save workspace migrations into the database
|
||||
await workspaceMigrationRepository.save(workspaceMigrations);
|
||||
|
||||
if (!options.applyChanges) {
|
||||
// Rollback transactions
|
||||
await queryRunner.rollbackTransaction();
|
||||
|
||||
await queryRunner.release();
|
||||
|
||||
return {
|
||||
workspaceMigrations,
|
||||
metadataEntities,
|
||||
};
|
||||
}
|
||||
|
||||
// Commit the transaction
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
// Apply pending migrations
|
||||
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
|
||||
workspaceId,
|
||||
);
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
console.error('Fix of issues failed with:', error);
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
|
||||
return {
|
||||
workspaceMigrations,
|
||||
metadataEntities,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { DataSourceModule } from 'src/engine-metadata/data-source/data-source.module';
|
||||
import { ObjectMetadataModule } from 'src/engine-metadata/object-metadata/object-metadata.module';
|
||||
import { WorkspaceMigrationModule } from 'src/engine-metadata/workspace-migration/workspace-migration.module';
|
||||
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
|
||||
import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module';
|
||||
import { WorkspaceHealthModule } from 'src/engine/workspace-manager/workspace-health/workspace-health.module';
|
||||
|
||||
import { WorkspaceManagerService } from './workspace-manager.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
WorkspaceDataSourceModule,
|
||||
WorkspaceMigrationModule,
|
||||
ObjectMetadataModule,
|
||||
DataSourceModule,
|
||||
WorkspaceSyncMetadataModule,
|
||||
WorkspaceHealthModule,
|
||||
],
|
||||
exports: [WorkspaceManagerService],
|
||||
providers: [WorkspaceManagerService],
|
||||
})
|
||||
export class WorkspaceManagerModule {}
|
||||
@ -0,0 +1,187 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { DataSourceService } from 'src/engine-metadata/data-source/data-source.service';
|
||||
import { ObjectMetadataService } from 'src/engine-metadata/object-metadata/object-metadata.service';
|
||||
import { WorkspaceMigrationService } from 'src/engine-metadata/workspace-migration/workspace-migration.service';
|
||||
import { standardObjectsPrefillData } from 'src/engine/workspace-manager/standard-objects-prefill-data/standard-objects-prefill-data';
|
||||
import { demoObjectsPrefillData } from 'src/engine/workspace-manager/demo-objects-prefill-data/demo-objects-prefill-data';
|
||||
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
|
||||
import { DataSourceEntity } from 'src/engine-metadata/data-source/data-source.entity';
|
||||
import { WorkspaceSyncMetadataService } from 'src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.service';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceManagerService {
|
||||
constructor(
|
||||
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
|
||||
private readonly workspaceMigrationService: WorkspaceMigrationService,
|
||||
private readonly objectMetadataService: ObjectMetadataService,
|
||||
private readonly dataSourceService: DataSourceService,
|
||||
private readonly workspaceSyncMetadataService: WorkspaceSyncMetadataService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Init a workspace by creating a new data source and running all migrations
|
||||
* @param workspaceId
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
public async init(workspaceId: string): Promise<void> {
|
||||
const schemaName =
|
||||
await this.workspaceDataSourceService.createWorkspaceDBSchema(
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const dataSourceMetadata =
|
||||
await this.dataSourceService.createDataSourceMetadata(
|
||||
workspaceId,
|
||||
schemaName,
|
||||
);
|
||||
|
||||
await this.setWorkspaceMaxRow(workspaceId, schemaName);
|
||||
|
||||
await this.workspaceSyncMetadataService.synchronize({
|
||||
workspaceId,
|
||||
dataSourceId: dataSourceMetadata.id,
|
||||
});
|
||||
|
||||
await this.prefillWorkspaceWithStandardObjects(
|
||||
dataSourceMetadata,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* InitDemo a workspace by creating a new data source and running all migrations
|
||||
* @param workspaceId
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
public async initDemo(workspaceId: string): Promise<void> {
|
||||
const schemaName =
|
||||
await this.workspaceDataSourceService.createWorkspaceDBSchema(
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const dataSourceMetadata =
|
||||
await this.dataSourceService.createDataSourceMetadata(
|
||||
workspaceId,
|
||||
schemaName,
|
||||
);
|
||||
|
||||
await this.setWorkspaceMaxRow(workspaceId, schemaName);
|
||||
|
||||
await this.workspaceSyncMetadataService.synchronize({
|
||||
workspaceId,
|
||||
dataSourceId: dataSourceMetadata.id,
|
||||
});
|
||||
|
||||
await this.prefillWorkspaceWithDemoObjects(dataSourceMetadata, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Check if the workspace schema has already been created or not
|
||||
*
|
||||
* @param workspaceId
|
||||
* @Returns Promise<boolean>
|
||||
*/
|
||||
public async doesDataSourceExist(workspaceId: string): Promise<boolean> {
|
||||
const dataSource =
|
||||
await this.dataSourceService.getDataSourcesMetadataFromWorkspaceId(
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
return dataSource.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* We are updating the pg_graphql max_rows from 30 (default value) to 60
|
||||
*
|
||||
* @params workspaceId, schemaName
|
||||
* @param workspaceId
|
||||
*/
|
||||
private async setWorkspaceMaxRow(workspaceId, schemaName) {
|
||||
const workspaceDataSource =
|
||||
await this.workspaceDataSourceService.connectToWorkspaceDataSource(
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
await workspaceDataSource.query(
|
||||
`comment on schema ${schemaName} is e'@graphql({"max_rows": 60})'`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* We are prefilling a few standard objects with data to make it easier for the user to get started.
|
||||
*
|
||||
* @param dataSourceMetadata
|
||||
* @param workspaceId
|
||||
*/
|
||||
private async prefillWorkspaceWithStandardObjects(
|
||||
dataSourceMetadata: DataSourceEntity,
|
||||
workspaceId: string,
|
||||
) {
|
||||
const workspaceDataSource =
|
||||
await this.workspaceDataSourceService.connectToWorkspaceDataSource(
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!workspaceDataSource) {
|
||||
throw new Error('Could not connect to workspace data source');
|
||||
}
|
||||
|
||||
const createdObjectMetadata =
|
||||
await this.objectMetadataService.findManyWithinWorkspace(workspaceId);
|
||||
|
||||
await standardObjectsPrefillData(
|
||||
workspaceDataSource,
|
||||
dataSourceMetadata.schema,
|
||||
createdObjectMetadata,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* We are prefilling a few demo objects with data to make it easier for the user to get started.
|
||||
*
|
||||
* @param dataSourceMetadata
|
||||
* @param workspaceId
|
||||
*/
|
||||
private async prefillWorkspaceWithDemoObjects(
|
||||
dataSourceMetadata: DataSourceEntity,
|
||||
workspaceId: string,
|
||||
) {
|
||||
const workspaceDataSource =
|
||||
await this.workspaceDataSourceService.connectToWorkspaceDataSource(
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!workspaceDataSource) {
|
||||
throw new Error('Could not connect to workspace data source');
|
||||
}
|
||||
|
||||
const createdObjectMetadata =
|
||||
await this.objectMetadataService.findManyWithinWorkspace(workspaceId);
|
||||
|
||||
await demoObjectsPrefillData(
|
||||
workspaceDataSource,
|
||||
dataSourceMetadata.schema,
|
||||
createdObjectMetadata,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Delete a workspace by deleting all metadata and the schema
|
||||
*
|
||||
* @param workspaceId
|
||||
*/
|
||||
public async delete(workspaceId: string): Promise<void> {
|
||||
// Delete data from metadata tables
|
||||
await this.objectMetadataService.deleteObjectsMetadata(workspaceId);
|
||||
await this.workspaceMigrationService.delete(workspaceId);
|
||||
await this.dataSourceService.delete(workspaceId);
|
||||
// Delete schema
|
||||
await this.workspaceDataSourceService.deleteWorkspaceDBSchema(workspaceId);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import { WorkspaceMigrationObjectFactory } from './workspace-migration-object.factory';
|
||||
import { WorkspaceMigrationFieldFactory } from './workspace-migration-field.factory';
|
||||
import { WorkspaceMigrationRelationFactory } from './workspace-migration-relation.factory';
|
||||
|
||||
export const workspaceMigrationBuilderFactories = [
|
||||
WorkspaceMigrationObjectFactory,
|
||||
WorkspaceMigrationFieldFactory,
|
||||
WorkspaceMigrationRelationFactory,
|
||||
];
|
||||
@ -0,0 +1,194 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceMigrationBuilderAction } from 'src/engine/workspace-manager/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface';
|
||||
|
||||
import {
|
||||
FieldMetadataEntity,
|
||||
FieldMetadataType,
|
||||
} from 'src/engine-metadata/field-metadata/field-metadata.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import {
|
||||
WorkspaceMigrationColumnActionType,
|
||||
WorkspaceMigrationEntity,
|
||||
WorkspaceMigrationTableAction,
|
||||
} from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
|
||||
import { computeObjectTargetTable } from 'src/engine-workspace/utils/compute-object-target-table.util';
|
||||
import { WorkspaceMigrationFactory } from 'src/engine-metadata/workspace-migration/workspace-migration.factory';
|
||||
import { generateMigrationName } from 'src/engine-metadata/workspace-migration/utils/generate-migration-name.util';
|
||||
|
||||
export interface FieldMetadataUpdate {
|
||||
current: FieldMetadataEntity;
|
||||
altered: FieldMetadataEntity;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceMigrationFieldFactory {
|
||||
constructor(
|
||||
private readonly workspaceMigrationFactory: WorkspaceMigrationFactory,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
originalObjectMetadataCollection: ObjectMetadataEntity[],
|
||||
fieldMetadataCollection: FieldMetadataEntity[],
|
||||
action:
|
||||
| WorkspaceMigrationBuilderAction.CREATE
|
||||
| WorkspaceMigrationBuilderAction.DELETE,
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]>;
|
||||
|
||||
async create(
|
||||
originalObjectMetadataCollection: ObjectMetadataEntity[],
|
||||
fieldMetadataUpdateCollection: FieldMetadataUpdate[],
|
||||
action: WorkspaceMigrationBuilderAction.UPDATE,
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]>;
|
||||
|
||||
async create(
|
||||
originalObjectMetadataCollection: ObjectMetadataEntity[],
|
||||
fieldMetadataCollectionOrFieldMetadataUpdateCollection:
|
||||
| FieldMetadataEntity[]
|
||||
| FieldMetadataUpdate[],
|
||||
action: WorkspaceMigrationBuilderAction,
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
const originalObjectMetadataMap = originalObjectMetadataCollection.reduce(
|
||||
(result, currentObject) => {
|
||||
result[currentObject.id] = currentObject;
|
||||
|
||||
return result;
|
||||
},
|
||||
{} as Record<string, ObjectMetadataEntity>,
|
||||
);
|
||||
|
||||
switch (action) {
|
||||
case WorkspaceMigrationBuilderAction.CREATE:
|
||||
return this.createFieldMigration(
|
||||
originalObjectMetadataMap,
|
||||
fieldMetadataCollectionOrFieldMetadataUpdateCollection as FieldMetadataEntity[],
|
||||
);
|
||||
case WorkspaceMigrationBuilderAction.UPDATE:
|
||||
return this.updateFieldMigration(
|
||||
originalObjectMetadataMap,
|
||||
fieldMetadataCollectionOrFieldMetadataUpdateCollection as FieldMetadataUpdate[],
|
||||
);
|
||||
case WorkspaceMigrationBuilderAction.DELETE:
|
||||
return this.deleteFieldMigration(
|
||||
originalObjectMetadataMap,
|
||||
fieldMetadataCollectionOrFieldMetadataUpdateCollection as FieldMetadataEntity[],
|
||||
);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async createFieldMigration(
|
||||
originalObjectMetadataMap: Record<string, ObjectMetadataEntity>,
|
||||
fieldMetadataCollection: FieldMetadataEntity[],
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
|
||||
|
||||
for (const fieldMetadata of fieldMetadataCollection) {
|
||||
if (fieldMetadata.type === FieldMetadataType.RELATION) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const migrations: WorkspaceMigrationTableAction[] = [
|
||||
{
|
||||
name: computeObjectTargetTable(
|
||||
originalObjectMetadataMap[fieldMetadata.objectMetadataId],
|
||||
),
|
||||
action: 'alter',
|
||||
columns: this.workspaceMigrationFactory.createColumnActions(
|
||||
WorkspaceMigrationColumnActionType.CREATE,
|
||||
fieldMetadata,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
workspaceMigrations.push({
|
||||
workspaceId: fieldMetadata.workspaceId,
|
||||
name: generateMigrationName(`create-${fieldMetadata.name}`),
|
||||
isCustom: false,
|
||||
migrations,
|
||||
});
|
||||
}
|
||||
|
||||
return workspaceMigrations;
|
||||
}
|
||||
|
||||
private async updateFieldMigration(
|
||||
originalObjectMetadataMap: Record<string, ObjectMetadataEntity>,
|
||||
fieldMetadataUpdateCollection: FieldMetadataUpdate[],
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
|
||||
|
||||
for (const fieldMetadataUpdate of fieldMetadataUpdateCollection) {
|
||||
// Skip relations, because they're just representation and not real columns
|
||||
if (fieldMetadataUpdate.altered.type === FieldMetadataType.RELATION) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const migrations: WorkspaceMigrationTableAction[] = [
|
||||
{
|
||||
name: computeObjectTargetTable(
|
||||
originalObjectMetadataMap[
|
||||
fieldMetadataUpdate.current.objectMetadataId
|
||||
],
|
||||
),
|
||||
action: 'alter',
|
||||
columns: this.workspaceMigrationFactory.createColumnActions(
|
||||
WorkspaceMigrationColumnActionType.ALTER,
|
||||
fieldMetadataUpdate.current,
|
||||
fieldMetadataUpdate.altered,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
workspaceMigrations.push({
|
||||
workspaceId: fieldMetadataUpdate.current.workspaceId,
|
||||
name: generateMigrationName(
|
||||
`update-${fieldMetadataUpdate.altered.name}`,
|
||||
),
|
||||
isCustom: false,
|
||||
migrations,
|
||||
});
|
||||
}
|
||||
|
||||
return workspaceMigrations;
|
||||
}
|
||||
|
||||
private async deleteFieldMigration(
|
||||
originalObjectMetadataMap: Record<string, ObjectMetadataEntity>,
|
||||
fieldMetadataCollection: FieldMetadataEntity[],
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
|
||||
|
||||
for (const fieldMetadata of fieldMetadataCollection) {
|
||||
// We're skipping relation fields, because they're just representation and not real columns
|
||||
if (fieldMetadata.type === FieldMetadataType.RELATION) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const migrations: WorkspaceMigrationTableAction[] = [
|
||||
{
|
||||
name: computeObjectTargetTable(
|
||||
originalObjectMetadataMap[fieldMetadata.objectMetadataId],
|
||||
),
|
||||
action: 'alter',
|
||||
columns: [
|
||||
{
|
||||
action: WorkspaceMigrationColumnActionType.DROP,
|
||||
columnName: fieldMetadata.name,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
workspaceMigrations.push({
|
||||
workspaceId: fieldMetadata.workspaceId,
|
||||
name: generateMigrationName(`delete-${fieldMetadata.name}`),
|
||||
isCustom: false,
|
||||
migrations,
|
||||
});
|
||||
}
|
||||
|
||||
return workspaceMigrations;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceMigrationBuilderAction } from 'src/engine/workspace-manager/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface';
|
||||
|
||||
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import {
|
||||
WorkspaceMigrationColumnActionType,
|
||||
WorkspaceMigrationEntity,
|
||||
WorkspaceMigrationTableAction,
|
||||
} from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
|
||||
import { computeObjectTargetTable } from 'src/engine-workspace/utils/compute-object-target-table.util';
|
||||
import { WorkspaceMigrationFactory } from 'src/engine-metadata/workspace-migration/workspace-migration.factory';
|
||||
import { generateMigrationName } from 'src/engine-metadata/workspace-migration/utils/generate-migration-name.util';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceMigrationObjectFactory {
|
||||
constructor(
|
||||
private readonly workspaceMigrationFactory: WorkspaceMigrationFactory,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
action: WorkspaceMigrationBuilderAction,
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
switch (action) {
|
||||
case WorkspaceMigrationBuilderAction.CREATE:
|
||||
return this.createObjectMigration(objectMetadataCollection);
|
||||
case WorkspaceMigrationBuilderAction.DELETE:
|
||||
return this.deleteObjectMigration(objectMetadataCollection);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async createObjectMigration(
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
|
||||
|
||||
for (const objectMetadata of objectMetadataCollection) {
|
||||
const migrations: WorkspaceMigrationTableAction[] = [
|
||||
{
|
||||
name: computeObjectTargetTable(objectMetadata),
|
||||
action: 'create',
|
||||
},
|
||||
];
|
||||
|
||||
for (const field of objectMetadata.fields) {
|
||||
if (field.type === FieldMetadataType.RELATION) {
|
||||
continue;
|
||||
}
|
||||
|
||||
migrations.push({
|
||||
name: computeObjectTargetTable(objectMetadata),
|
||||
action: 'alter',
|
||||
columns: this.workspaceMigrationFactory.createColumnActions(
|
||||
WorkspaceMigrationColumnActionType.CREATE,
|
||||
field,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
workspaceMigrations.push({
|
||||
workspaceId: objectMetadata.workspaceId,
|
||||
name: generateMigrationName(`create-${objectMetadata.nameSingular}`),
|
||||
isCustom: false,
|
||||
migrations,
|
||||
});
|
||||
}
|
||||
|
||||
return workspaceMigrations;
|
||||
}
|
||||
|
||||
private async deleteObjectMigration(
|
||||
objectMetadataCollection: ObjectMetadataEntity[],
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
|
||||
|
||||
for (const objectMetadata of objectMetadataCollection) {
|
||||
const migrations: WorkspaceMigrationTableAction[] = [
|
||||
{
|
||||
name: computeObjectTargetTable(objectMetadata),
|
||||
action: 'drop',
|
||||
columns: [],
|
||||
},
|
||||
];
|
||||
|
||||
workspaceMigrations.push({
|
||||
workspaceId: objectMetadata.workspaceId,
|
||||
name: generateMigrationName(`delete-${objectMetadata.nameSingular}`),
|
||||
isCustom: false,
|
||||
migrations,
|
||||
});
|
||||
}
|
||||
|
||||
return workspaceMigrations;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,197 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceMigrationBuilderAction } from 'src/engine/workspace-manager/workspace-migration-builder/interfaces/workspace-migration-builder-action.interface';
|
||||
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import {
|
||||
WorkspaceMigrationColumnActionType,
|
||||
WorkspaceMigrationEntity,
|
||||
WorkspaceMigrationTableAction,
|
||||
} from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
|
||||
import { computeObjectTargetTable } from 'src/engine-workspace/utils/compute-object-target-table.util';
|
||||
import {
|
||||
RelationMetadataEntity,
|
||||
RelationMetadataType,
|
||||
} from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
|
||||
import { camelCase } from 'src/utils/camel-case';
|
||||
import { generateMigrationName } from 'src/engine-metadata/workspace-migration/utils/generate-migration-name.util';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceMigrationRelationFactory {
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Deletion of the relation is handled by field deletion
|
||||
*/
|
||||
async create(
|
||||
originalObjectMetadataCollection: ObjectMetadataEntity[],
|
||||
relationMetadataCollection: RelationMetadataEntity[],
|
||||
action: WorkspaceMigrationBuilderAction,
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
const originalObjectMetadataMap = originalObjectMetadataCollection.reduce(
|
||||
(result, currentObject) => {
|
||||
result[currentObject.id] = currentObject;
|
||||
|
||||
return result;
|
||||
},
|
||||
{} as Record<string, ObjectMetadataEntity>,
|
||||
);
|
||||
|
||||
switch (action) {
|
||||
case WorkspaceMigrationBuilderAction.CREATE:
|
||||
return this.createRelationMigration(
|
||||
originalObjectMetadataMap,
|
||||
relationMetadataCollection,
|
||||
);
|
||||
case WorkspaceMigrationBuilderAction.UPDATE:
|
||||
return this.updateRelationMigration(
|
||||
originalObjectMetadataMap,
|
||||
relationMetadataCollection,
|
||||
);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async updateRelationMigration(
|
||||
originalObjectMetadataMap: Record<string, ObjectMetadataEntity>,
|
||||
relationMetadataCollection: RelationMetadataEntity[],
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
|
||||
|
||||
for (const relationMetadata of relationMetadataCollection) {
|
||||
const toObjectMetadata =
|
||||
originalObjectMetadataMap[relationMetadata.toObjectMetadataId];
|
||||
const fromObjectMetadata =
|
||||
originalObjectMetadataMap[relationMetadata.fromObjectMetadataId];
|
||||
|
||||
if (!toObjectMetadata) {
|
||||
throw new Error(
|
||||
`ObjectMetadata with id ${relationMetadata.toObjectMetadataId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!fromObjectMetadata) {
|
||||
throw new Error(
|
||||
`ObjectMetadata with id ${relationMetadata.fromObjectMetadataId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
const toFieldMetadata = toObjectMetadata.fields.find(
|
||||
(field) => field.id === relationMetadata.toFieldMetadataId,
|
||||
);
|
||||
|
||||
if (!toFieldMetadata) {
|
||||
throw new Error(
|
||||
`FieldMetadata with id ${relationMetadata.toFieldMetadataId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
const migrations: WorkspaceMigrationTableAction[] = [
|
||||
{
|
||||
name: computeObjectTargetTable(toObjectMetadata),
|
||||
action: 'alter',
|
||||
columns: [
|
||||
{
|
||||
action: WorkspaceMigrationColumnActionType.DROP_FOREIGN_KEY,
|
||||
columnName: `${camelCase(toFieldMetadata.name)}Id`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: computeObjectTargetTable(toObjectMetadata),
|
||||
action: 'alter',
|
||||
columns: [
|
||||
{
|
||||
action: WorkspaceMigrationColumnActionType.CREATE_FOREIGN_KEY,
|
||||
columnName: `${camelCase(toFieldMetadata.name)}Id`,
|
||||
referencedTableName: computeObjectTargetTable(fromObjectMetadata),
|
||||
referencedTableColumnName: 'id',
|
||||
isUnique:
|
||||
relationMetadata.relationType ===
|
||||
RelationMetadataType.ONE_TO_ONE,
|
||||
onDelete: relationMetadata.onDeleteAction,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
workspaceMigrations.push({
|
||||
workspaceId: relationMetadata.workspaceId,
|
||||
name: generateMigrationName(
|
||||
`update-relation-from-${fromObjectMetadata.nameSingular}-to-${toObjectMetadata.nameSingular}`,
|
||||
),
|
||||
isCustom: false,
|
||||
migrations,
|
||||
});
|
||||
}
|
||||
|
||||
return workspaceMigrations;
|
||||
}
|
||||
|
||||
private async createRelationMigration(
|
||||
originalObjectMetadataMap: Record<string, ObjectMetadataEntity>,
|
||||
relationMetadataCollection: RelationMetadataEntity[],
|
||||
): Promise<Partial<WorkspaceMigrationEntity>[]> {
|
||||
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
|
||||
|
||||
for (const relationMetadata of relationMetadataCollection) {
|
||||
const toObjectMetadata =
|
||||
originalObjectMetadataMap[relationMetadata.toObjectMetadataId];
|
||||
const fromObjectMetadata =
|
||||
originalObjectMetadataMap[relationMetadata.fromObjectMetadataId];
|
||||
|
||||
if (!toObjectMetadata) {
|
||||
throw new Error(
|
||||
`ObjectMetadata with id ${relationMetadata.toObjectMetadataId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!fromObjectMetadata) {
|
||||
throw new Error(
|
||||
`ObjectMetadata with id ${relationMetadata.fromObjectMetadataId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
const toFieldMetadata = toObjectMetadata.fields.find(
|
||||
(field) => field.id === relationMetadata.toFieldMetadataId,
|
||||
);
|
||||
|
||||
if (!toFieldMetadata) {
|
||||
throw new Error(
|
||||
`FieldMetadata with id ${relationMetadata.toFieldMetadataId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
const migrations: WorkspaceMigrationTableAction[] = [
|
||||
{
|
||||
name: computeObjectTargetTable(toObjectMetadata),
|
||||
action: 'alter',
|
||||
columns: [
|
||||
{
|
||||
action: WorkspaceMigrationColumnActionType.CREATE_FOREIGN_KEY,
|
||||
columnName: `${camelCase(toFieldMetadata.name)}Id`,
|
||||
referencedTableName: computeObjectTargetTable(fromObjectMetadata),
|
||||
referencedTableColumnName: 'id',
|
||||
isUnique:
|
||||
relationMetadata.relationType ===
|
||||
RelationMetadataType.ONE_TO_ONE,
|
||||
onDelete: relationMetadata.onDeleteAction,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
workspaceMigrations.push({
|
||||
workspaceId: relationMetadata.workspaceId,
|
||||
name: generateMigrationName(
|
||||
`create-relation-from-${fromObjectMetadata.nameSingular}-to-${toObjectMetadata.nameSingular}`,
|
||||
),
|
||||
isCustom: false,
|
||||
migrations,
|
||||
});
|
||||
}
|
||||
|
||||
return workspaceMigrations;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
export enum WorkspaceMigrationBuilderAction {
|
||||
CREATE = 'create',
|
||||
UPDATE = 'update',
|
||||
DELETE = 'delete',
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceMigrationModule } from 'src/engine-metadata/workspace-migration/workspace-migration.module';
|
||||
|
||||
import { workspaceMigrationBuilderFactories } from './factories';
|
||||
|
||||
@Module({
|
||||
imports: [WorkspaceMigrationModule],
|
||||
providers: [...workspaceMigrationBuilderFactories],
|
||||
exports: [...workspaceMigrationBuilderFactories],
|
||||
})
|
||||
export class WorkspaceMigrationBuilderModule {}
|
||||
@ -0,0 +1,37 @@
|
||||
import { Command, CommandRunner, Option } from 'nest-commander';
|
||||
|
||||
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
|
||||
|
||||
interface ExecuteWorkspaceMigrationsOptions {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
@Command({
|
||||
name: 'workspace:apply-pending-migrations',
|
||||
description: 'Apply pending migrations',
|
||||
})
|
||||
export class WorkspaceExecutePendingMigrationsCommand extends CommandRunner {
|
||||
constructor(
|
||||
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(
|
||||
_passedParam: string[],
|
||||
options: ExecuteWorkspaceMigrationsOptions,
|
||||
): Promise<void> {
|
||||
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
|
||||
options.workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-w, --workspace-id [workspace_id]',
|
||||
description: 'workspace id',
|
||||
required: true,
|
||||
})
|
||||
parseWorkspaceId(value: string): string {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module';
|
||||
|
||||
import { WorkspaceExecutePendingMigrationsCommand } from './workspace-execute-pending-migrations.command';
|
||||
|
||||
@Module({
|
||||
imports: [WorkspaceMigrationRunnerModule],
|
||||
providers: [WorkspaceExecutePendingMigrationsCommand],
|
||||
})
|
||||
export class WorkspaceMigrationRunnerCommandsModule {}
|
||||
@ -0,0 +1,192 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { QueryRunner } from 'typeorm';
|
||||
|
||||
import { WorkspaceMigrationColumnAlter } from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
|
||||
import { serializeDefaultValue } from 'src/engine-metadata/field-metadata/utils/serialize-default-value';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceMigrationEnumService {
|
||||
async alterEnum(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
migrationColumn: WorkspaceMigrationColumnAlter,
|
||||
) {
|
||||
const columnDefinition = migrationColumn.alteredColumnDefinition;
|
||||
const oldEnumTypeName = `${tableName}_${columnDefinition.columnName}_enum`;
|
||||
const newEnumTypeName = `${tableName}_${columnDefinition.columnName}_enum_new`;
|
||||
const enumValues =
|
||||
columnDefinition.enum?.map((enumValue) => {
|
||||
if (typeof enumValue === 'string') {
|
||||
return enumValue;
|
||||
}
|
||||
|
||||
return enumValue.to;
|
||||
}) ?? [];
|
||||
|
||||
if (!columnDefinition.isNullable && !columnDefinition.defaultValue) {
|
||||
columnDefinition.defaultValue = serializeDefaultValue(enumValues[0]);
|
||||
}
|
||||
|
||||
// Create new enum type with new values
|
||||
await this.createNewEnumType(
|
||||
newEnumTypeName,
|
||||
queryRunner,
|
||||
schemaName,
|
||||
enumValues,
|
||||
);
|
||||
|
||||
// Temporarily change column type to text
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "${schemaName}"."${tableName}"
|
||||
ALTER COLUMN "${columnDefinition.columnName}" TYPE TEXT
|
||||
`);
|
||||
|
||||
// Migrate existing values to new values
|
||||
await this.migrateEnumValues(
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
migrationColumn,
|
||||
);
|
||||
|
||||
// Update existing rows to handle missing values
|
||||
await this.handleMissingEnumValues(
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
migrationColumn,
|
||||
enumValues,
|
||||
);
|
||||
|
||||
// Alter column type to new enum
|
||||
await this.updateColumnToNewEnum(
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnDefinition.columnName,
|
||||
newEnumTypeName,
|
||||
columnDefinition.defaultValue,
|
||||
);
|
||||
|
||||
// Drop old enum type
|
||||
await this.dropOldEnumType(queryRunner, schemaName, oldEnumTypeName);
|
||||
|
||||
// Rename new enum type to old enum type name
|
||||
await this.renameEnumType(
|
||||
queryRunner,
|
||||
schemaName,
|
||||
oldEnumTypeName,
|
||||
newEnumTypeName,
|
||||
);
|
||||
}
|
||||
|
||||
private async createNewEnumType(
|
||||
name: string,
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
newValues: string[],
|
||||
) {
|
||||
const enumValues = newValues
|
||||
.map((value) => `'${value.replace(/'/g, "''")}'`)
|
||||
.join(', ');
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE TYPE "${schemaName}"."${name}" AS ENUM (${enumValues})`,
|
||||
);
|
||||
}
|
||||
|
||||
private async migrateEnumValues(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
migrationColumn: WorkspaceMigrationColumnAlter,
|
||||
) {
|
||||
const columnDefinition = migrationColumn.alteredColumnDefinition;
|
||||
|
||||
if (!columnDefinition.enum) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const enumValue of columnDefinition.enum) {
|
||||
// Skip string values
|
||||
if (typeof enumValue === 'string') {
|
||||
continue;
|
||||
}
|
||||
await queryRunner.query(`
|
||||
UPDATE "${schemaName}"."${tableName}"
|
||||
SET "${columnDefinition.columnName}" = '${enumValue.to}'
|
||||
WHERE "${columnDefinition.columnName}" = '${enumValue.from}'
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMissingEnumValues(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
migrationColumn: WorkspaceMigrationColumnAlter,
|
||||
enumValues: string[],
|
||||
) {
|
||||
const columnDefinition = migrationColumn.alteredColumnDefinition;
|
||||
|
||||
// Set missing values to null or default value
|
||||
let defaultValue = 'NULL';
|
||||
|
||||
if (columnDefinition.defaultValue) {
|
||||
if (Array.isArray(columnDefinition.defaultValue)) {
|
||||
defaultValue = `ARRAY[${columnDefinition.defaultValue
|
||||
.map((e) => `'${e}'`)
|
||||
.join(', ')}]`;
|
||||
} else {
|
||||
defaultValue = columnDefinition.defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE "${schemaName}"."${tableName}"
|
||||
SET "${columnDefinition.columnName}" = ${defaultValue}
|
||||
WHERE "${columnDefinition.columnName}" NOT IN (${enumValues
|
||||
.map((e) => `'${e}'`)
|
||||
.join(', ')})
|
||||
`);
|
||||
}
|
||||
|
||||
private async updateColumnToNewEnum(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
newEnumTypeName: string,
|
||||
newDefaultValue: string,
|
||||
) {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "${schemaName}"."${tableName}" ALTER COLUMN "${columnName}" DROP DEFAULT,
|
||||
ALTER COLUMN "${columnName}" TYPE "${schemaName}"."${newEnumTypeName}" USING ("${columnName}"::text::"${schemaName}"."${newEnumTypeName}"),
|
||||
ALTER COLUMN "${columnName}" SET DEFAULT ${newDefaultValue}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async dropOldEnumType(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
oldEnumTypeName: string,
|
||||
) {
|
||||
await queryRunner.query(
|
||||
`DROP TYPE IF EXISTS "${schemaName}"."${oldEnumTypeName}"`,
|
||||
);
|
||||
}
|
||||
|
||||
private async renameEnumType(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
oldEnumTypeName: string,
|
||||
newEnumTypeName: string,
|
||||
) {
|
||||
await queryRunner.query(`
|
||||
ALTER TYPE "${schemaName}"."${newEnumTypeName}"
|
||||
RENAME TO "${oldEnumTypeName}"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { QueryRunner } from 'typeorm';
|
||||
|
||||
import { WorkspaceMigrationColumnAlter } from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceMigrationTypeService {
|
||||
constructor() {}
|
||||
|
||||
async alterType(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
migrationColumn: WorkspaceMigrationColumnAlter,
|
||||
) {
|
||||
const columnDefinition = migrationColumn.alteredColumnDefinition;
|
||||
|
||||
// Update the column type
|
||||
// If casting is not possible, the query will fail
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "${schemaName}"."${tableName}"
|
||||
ALTER COLUMN "${columnDefinition.columnName}" TYPE ${columnDefinition.columnType}
|
||||
USING "${columnDefinition.columnName}"::${columnDefinition.columnType}
|
||||
`);
|
||||
|
||||
// Update the column default value
|
||||
if (columnDefinition.defaultValue) {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "${schemaName}"."${tableName}"
|
||||
ALTER COLUMN "${columnDefinition.columnName}" SET DEFAULT ${columnDefinition.defaultValue}::${columnDefinition.columnType};
|
||||
`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
import { RelationOnDeleteAction } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
|
||||
|
||||
export const convertOnDeleteActionToOnDelete = (
|
||||
onDeleteAction: RelationOnDeleteAction | undefined,
|
||||
): 'CASCADE' | 'SET NULL' | 'RESTRICT' | 'NO ACTION' | undefined => {
|
||||
if (!onDeleteAction) {
|
||||
return 'SET NULL';
|
||||
}
|
||||
|
||||
switch (onDeleteAction) {
|
||||
case 'CASCADE':
|
||||
return 'CASCADE';
|
||||
case 'SET_NULL':
|
||||
return 'SET NULL';
|
||||
case 'RESTRICT':
|
||||
return 'RESTRICT';
|
||||
case 'NO_ACTION':
|
||||
return 'NO ACTION';
|
||||
default:
|
||||
throw new Error('Invalid onDeleteAction');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,25 @@
|
||||
import { TableColumnOptions } from 'typeorm';
|
||||
|
||||
export const customTableDefaultColumns: TableColumnOptions[] = [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'uuid',
|
||||
isPrimary: true,
|
||||
default: 'public.uuid_generate_v4()',
|
||||
},
|
||||
{
|
||||
name: 'createdAt',
|
||||
type: 'timestamp',
|
||||
default: 'now()',
|
||||
},
|
||||
{
|
||||
name: 'updatedAt',
|
||||
type: 'timestamp',
|
||||
default: 'now()',
|
||||
},
|
||||
{
|
||||
name: 'deletedAt',
|
||||
type: 'timestamp',
|
||||
isNullable: true,
|
||||
},
|
||||
];
|
||||
@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceMigrationModule } from 'src/engine-metadata/workspace-migration/workspace-migration.module';
|
||||
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
|
||||
import { WorkspaceCacheVersionModule } from 'src/engine-metadata/workspace-cache-version/workspace-cache-version.module';
|
||||
import { WorkspaceMigrationEnumService } from 'src/engine/workspace-manager/workspace-migration-runner/services/workspace-migration-enum.service';
|
||||
|
||||
import { WorkspaceMigrationRunnerService } from './workspace-migration-runner.service';
|
||||
|
||||
import { WorkspaceMigrationTypeService } from './services/workspace-migration-type.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
WorkspaceDataSourceModule,
|
||||
WorkspaceMigrationModule,
|
||||
WorkspaceCacheVersionModule,
|
||||
],
|
||||
providers: [
|
||||
WorkspaceMigrationRunnerService,
|
||||
WorkspaceMigrationEnumService,
|
||||
WorkspaceMigrationTypeService,
|
||||
],
|
||||
exports: [WorkspaceMigrationRunnerService],
|
||||
})
|
||||
export class WorkspaceMigrationRunnerModule {}
|
||||
@ -0,0 +1,415 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
QueryRunner,
|
||||
Table,
|
||||
TableColumn,
|
||||
TableForeignKey,
|
||||
TableUnique,
|
||||
} from 'typeorm';
|
||||
|
||||
import { WorkspaceMigrationService } from 'src/engine-metadata/workspace-migration/workspace-migration.service';
|
||||
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
|
||||
import {
|
||||
WorkspaceMigrationTableAction,
|
||||
WorkspaceMigrationColumnAction,
|
||||
WorkspaceMigrationColumnActionType,
|
||||
WorkspaceMigrationColumnCreate,
|
||||
WorkspaceMigrationColumnCreateRelation,
|
||||
WorkspaceMigrationColumnAlter,
|
||||
WorkspaceMigrationColumnDropRelation,
|
||||
} from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
|
||||
import { WorkspaceCacheVersionService } from 'src/engine-metadata/workspace-cache-version/workspace-cache-version.service';
|
||||
import { WorkspaceMigrationEnumService } from 'src/engine/workspace-manager/workspace-migration-runner/services/workspace-migration-enum.service';
|
||||
import { convertOnDeleteActionToOnDelete } from 'src/engine/workspace-manager/workspace-migration-runner/utils/convert-on-delete-action-to-on-delete.util';
|
||||
|
||||
import { customTableDefaultColumns } from './utils/custom-table-default-column.util';
|
||||
import { WorkspaceMigrationTypeService } from './services/workspace-migration-type.service';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceMigrationRunnerService {
|
||||
constructor(
|
||||
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
|
||||
private readonly workspaceMigrationService: WorkspaceMigrationService,
|
||||
private readonly workspaceCacheVersionService: WorkspaceCacheVersionService,
|
||||
private readonly workspaceMigrationEnumService: WorkspaceMigrationEnumService,
|
||||
private readonly workspaceMigrationTypeService: WorkspaceMigrationTypeService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Executes pending migrations for a given workspace
|
||||
*
|
||||
* @param workspaceId string
|
||||
* @returns Promise<WorkspaceMigrationTableAction[]>
|
||||
*/
|
||||
public async executeMigrationFromPendingMigrations(
|
||||
workspaceId: string,
|
||||
): Promise<WorkspaceMigrationTableAction[]> {
|
||||
const workspaceDataSource =
|
||||
await this.workspaceDataSourceService.connectToWorkspaceDataSource(
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!workspaceDataSource) {
|
||||
throw new Error('Workspace data source not found');
|
||||
}
|
||||
|
||||
const pendingMigrations =
|
||||
await this.workspaceMigrationService.getPendingMigrations(workspaceId);
|
||||
|
||||
if (pendingMigrations.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const flattenedPendingMigrations: WorkspaceMigrationTableAction[] =
|
||||
pendingMigrations.reduce((acc, pendingMigration) => {
|
||||
return [...acc, ...pendingMigration.migrations];
|
||||
}, []);
|
||||
|
||||
const queryRunner = workspaceDataSource?.createQueryRunner();
|
||||
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
const schemaName =
|
||||
this.workspaceDataSourceService.getSchemaName(workspaceId);
|
||||
|
||||
try {
|
||||
// Loop over each migration and create or update the table
|
||||
for (const migration of flattenedPendingMigrations) {
|
||||
await this.handleTableChanges(queryRunner, schemaName, migration);
|
||||
}
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
} catch (error) {
|
||||
console.error('Error executing migration', error);
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
|
||||
// Update appliedAt date for each migration
|
||||
// TODO: Should be done after the migration is successful
|
||||
for (const pendingMigration of pendingMigrations) {
|
||||
await this.workspaceMigrationService.setAppliedAtForMigration(
|
||||
workspaceId,
|
||||
pendingMigration,
|
||||
);
|
||||
}
|
||||
|
||||
// Increment workspace cache version
|
||||
await this.workspaceCacheVersionService.incrementVersion(workspaceId);
|
||||
|
||||
return flattenedPendingMigrations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles table changes for a given migration
|
||||
*
|
||||
* @param queryRunner QueryRunner
|
||||
* @param schemaName string
|
||||
* @param tableMigration WorkspaceMigrationTableChange
|
||||
*/
|
||||
private async handleTableChanges(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableMigration: WorkspaceMigrationTableAction,
|
||||
) {
|
||||
switch (tableMigration.action) {
|
||||
case 'create':
|
||||
await this.createTable(queryRunner, schemaName, tableMigration.name);
|
||||
break;
|
||||
case 'alter':
|
||||
await this.handleColumnChanges(
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableMigration.name,
|
||||
tableMigration?.columns,
|
||||
);
|
||||
break;
|
||||
case 'drop':
|
||||
await queryRunner.dropTable(`${schemaName}.${tableMigration.name}`);
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
`Migration table action ${tableMigration.action} not supported`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a table for a given schema and table name
|
||||
*
|
||||
* @param queryRunner QueryRunner
|
||||
* @param schemaName string
|
||||
* @param tableName string
|
||||
*/
|
||||
private async createTable(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
) {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: tableName,
|
||||
schema: schemaName,
|
||||
columns: customTableDefaultColumns,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
// Enable totalCount for the table
|
||||
await queryRunner.query(`
|
||||
COMMENT ON TABLE "${schemaName}"."${tableName}" IS '@graphql({"totalCount": {"enabled": true}})';
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles column changes for a given migration
|
||||
*
|
||||
* @param queryRunner QueryRunner
|
||||
* @param schemaName string
|
||||
* @param tableName string
|
||||
* @param columnMigrations WorkspaceMigrationColumnAction[]
|
||||
* @returns
|
||||
*/
|
||||
private async handleColumnChanges(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
columnMigrations?: WorkspaceMigrationColumnAction[],
|
||||
) {
|
||||
if (!columnMigrations || columnMigrations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const columnMigration of columnMigrations) {
|
||||
switch (columnMigration.action) {
|
||||
case WorkspaceMigrationColumnActionType.CREATE:
|
||||
await this.createColumn(
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnMigration,
|
||||
);
|
||||
break;
|
||||
case WorkspaceMigrationColumnActionType.ALTER:
|
||||
await this.alterColumn(
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnMigration,
|
||||
);
|
||||
break;
|
||||
case WorkspaceMigrationColumnActionType.CREATE_FOREIGN_KEY:
|
||||
await this.createRelation(
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnMigration,
|
||||
);
|
||||
break;
|
||||
case WorkspaceMigrationColumnActionType.DROP_FOREIGN_KEY:
|
||||
await this.dropRelation(
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
columnMigration,
|
||||
);
|
||||
break;
|
||||
case WorkspaceMigrationColumnActionType.DROP:
|
||||
await queryRunner.dropColumn(
|
||||
`${schemaName}.${tableName}`,
|
||||
columnMigration.columnName,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Migration column action not supported`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a column for a given schema, table name, and column migration
|
||||
*
|
||||
* @param queryRunner QueryRunner
|
||||
* @param schemaName string
|
||||
* @param tableName string
|
||||
* @param migrationColumn WorkspaceMigrationColumnAction
|
||||
*/
|
||||
private async createColumn(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
migrationColumn: WorkspaceMigrationColumnCreate,
|
||||
) {
|
||||
const hasColumn = await queryRunner.hasColumn(
|
||||
`${schemaName}.${tableName}`,
|
||||
migrationColumn.columnName,
|
||||
);
|
||||
|
||||
if (hasColumn) {
|
||||
return;
|
||||
}
|
||||
|
||||
await queryRunner.addColumn(
|
||||
`${schemaName}.${tableName}`,
|
||||
new TableColumn({
|
||||
name: migrationColumn.columnName,
|
||||
type: migrationColumn.columnType,
|
||||
default: migrationColumn.defaultValue,
|
||||
enum: migrationColumn.enum?.filter(
|
||||
(value): value is string => typeof value === 'string',
|
||||
),
|
||||
isArray: migrationColumn.isArray,
|
||||
isNullable: migrationColumn.isNullable,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async alterColumn(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
migrationColumn: WorkspaceMigrationColumnAlter,
|
||||
) {
|
||||
const enumValues = migrationColumn.alteredColumnDefinition.enum;
|
||||
|
||||
// TODO: Maybe we can do something better if we can recreate the old `TableColumn` object
|
||||
if (enumValues) {
|
||||
// This is returning the old enum values to avoid TypeORM dropping the enum type
|
||||
await this.workspaceMigrationEnumService.alterEnum(
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
migrationColumn,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
migrationColumn.currentColumnDefinition.columnType !==
|
||||
migrationColumn.alteredColumnDefinition.columnType
|
||||
) {
|
||||
await this.workspaceMigrationTypeService.alterType(
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
migrationColumn,
|
||||
);
|
||||
|
||||
migrationColumn.currentColumnDefinition.columnType =
|
||||
migrationColumn.alteredColumnDefinition.columnType;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await queryRunner.changeColumn(
|
||||
`${schemaName}.${tableName}`,
|
||||
new TableColumn({
|
||||
name: migrationColumn.currentColumnDefinition.columnName,
|
||||
type: migrationColumn.currentColumnDefinition.columnType,
|
||||
default: migrationColumn.currentColumnDefinition.defaultValue,
|
||||
enum: migrationColumn.currentColumnDefinition.enum?.filter(
|
||||
(value): value is string => typeof value === 'string',
|
||||
),
|
||||
isArray: migrationColumn.currentColumnDefinition.isArray,
|
||||
isNullable: migrationColumn.currentColumnDefinition.isNullable,
|
||||
}),
|
||||
new TableColumn({
|
||||
name: migrationColumn.alteredColumnDefinition.columnName,
|
||||
type: migrationColumn.alteredColumnDefinition.columnType,
|
||||
default: migrationColumn.alteredColumnDefinition.defaultValue,
|
||||
enum: migrationColumn.currentColumnDefinition.enum?.filter(
|
||||
(value): value is string => typeof value === 'string',
|
||||
),
|
||||
isArray: migrationColumn.alteredColumnDefinition.isArray,
|
||||
isNullable: migrationColumn.alteredColumnDefinition.isNullable,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async createRelation(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
migrationColumn: WorkspaceMigrationColumnCreateRelation,
|
||||
) {
|
||||
await queryRunner.createForeignKey(
|
||||
`${schemaName}.${tableName}`,
|
||||
new TableForeignKey({
|
||||
columnNames: [migrationColumn.columnName],
|
||||
referencedColumnNames: [migrationColumn.referencedTableColumnName],
|
||||
referencedTableName: migrationColumn.referencedTableName,
|
||||
referencedSchema: schemaName,
|
||||
onDelete: convertOnDeleteActionToOnDelete(migrationColumn.onDelete),
|
||||
}),
|
||||
);
|
||||
|
||||
// Create unique constraint if for one to one relation
|
||||
if (migrationColumn.isUnique) {
|
||||
await queryRunner.createUniqueConstraint(
|
||||
`${schemaName}.${tableName}`,
|
||||
new TableUnique({
|
||||
name: `UNIQUE_${tableName}_${migrationColumn.columnName}`,
|
||||
columnNames: [migrationColumn.columnName],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async dropRelation(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
migrationColumn: WorkspaceMigrationColumnDropRelation,
|
||||
) {
|
||||
const foreignKeyName = await this.getForeignKeyName(
|
||||
queryRunner,
|
||||
schemaName,
|
||||
tableName,
|
||||
migrationColumn.columnName,
|
||||
);
|
||||
|
||||
if (!foreignKeyName) {
|
||||
throw new Error(
|
||||
`Foreign key not found for column ${migrationColumn.columnName}`,
|
||||
);
|
||||
}
|
||||
|
||||
await queryRunner.dropForeignKey(
|
||||
`${schemaName}.${tableName}`,
|
||||
foreignKeyName,
|
||||
);
|
||||
}
|
||||
|
||||
private async getForeignKeyName(
|
||||
queryRunner: QueryRunner,
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
): Promise<string | undefined> {
|
||||
const foreignKeys = await queryRunner.query(
|
||||
`
|
||||
SELECT
|
||||
tc.constraint_name AS constraint_name
|
||||
FROM
|
||||
information_schema.table_constraints AS tc
|
||||
JOIN
|
||||
information_schema.key_column_usage AS kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_schema = kcu.table_schema
|
||||
WHERE
|
||||
tc.constraint_type = 'FOREIGN KEY'
|
||||
AND tc.table_schema = $1
|
||||
AND tc.table_name = $2
|
||||
AND kcu.column_name = $3
|
||||
`,
|
||||
[schemaName, tableName, columnName],
|
||||
);
|
||||
|
||||
return foreignKeys[0]?.constraint_name;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,153 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { FieldMetadataEntity } from 'src/engine-metadata/field-metadata/field-metadata.entity';
|
||||
import { standardObjectMetadataDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects';
|
||||
import { StandardObjectFactory } from 'src/engine/workspace-manager/workspace-sync-metadata/factories/standard-object.factory';
|
||||
import { computeStandardObject } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/compute-standard-object.util';
|
||||
import { StandardFieldFactory } from 'src/engine/workspace-manager/workspace-sync-metadata/factories/standard-field.factory';
|
||||
import { CustomObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/custom-objects/custom.object-metadata';
|
||||
|
||||
@Command({
|
||||
name: 'workspace:add-standard-id',
|
||||
description: 'Add standard id to all metadata objects and fields',
|
||||
})
|
||||
export class AddStandardIdCommand extends CommandRunner {
|
||||
private readonly logger = new Logger(AddStandardIdCommand.name);
|
||||
|
||||
constructor(
|
||||
@InjectDataSource('metadata')
|
||||
private readonly metadataDataSource: DataSource,
|
||||
private readonly standardObjectFactory: StandardObjectFactory,
|
||||
private readonly standardFieldFactory: StandardFieldFactory,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
const queryRunner = this.metadataDataSource.createQueryRunner();
|
||||
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
const manager = queryRunner.manager;
|
||||
|
||||
this.logger.log('Adding standardId to metadata objects and fields');
|
||||
|
||||
try {
|
||||
const standardObjectMetadataCollection =
|
||||
this.standardObjectFactory.create(
|
||||
standardObjectMetadataDefinitions,
|
||||
{
|
||||
// We don't need to provide the workspace id and data source id as we're only adding standardId
|
||||
workspaceId: '',
|
||||
dataSourceId: '',
|
||||
},
|
||||
{
|
||||
IS_BLOCKLIST_ENABLED: true,
|
||||
IS_CALENDAR_ENABLED: true,
|
||||
},
|
||||
);
|
||||
const standardFieldMetadataCollection = this.standardFieldFactory.create(
|
||||
CustomObjectMetadata,
|
||||
{
|
||||
workspaceId: '',
|
||||
dataSourceId: '',
|
||||
},
|
||||
{
|
||||
IS_BLOCKLIST_ENABLED: true,
|
||||
IS_CALENDAR_ENABLED: true,
|
||||
},
|
||||
);
|
||||
|
||||
const objectMetadataRepository =
|
||||
manager.getRepository(ObjectMetadataEntity);
|
||||
const fieldMetadataRepository =
|
||||
manager.getRepository(FieldMetadataEntity);
|
||||
|
||||
/**
|
||||
* Update all object metadata with standard id
|
||||
*/
|
||||
const updateObjectMetadataCollection: Partial<ObjectMetadataEntity>[] =
|
||||
[];
|
||||
const updateFieldMetadataCollection: Partial<FieldMetadataEntity>[] = [];
|
||||
const originalObjectMetadataCollection =
|
||||
await objectMetadataRepository.find({
|
||||
where: {
|
||||
fields: { isCustom: false },
|
||||
},
|
||||
relations: ['fields'],
|
||||
});
|
||||
const customObjectMetadataCollection =
|
||||
originalObjectMetadataCollection.filter(
|
||||
(metadata) => metadata.isCustom,
|
||||
);
|
||||
const standardObjectMetadataMap = new Map(
|
||||
standardObjectMetadataCollection.map((metadata) => [
|
||||
metadata.nameSingular,
|
||||
metadata,
|
||||
]),
|
||||
);
|
||||
|
||||
for (const originalObjectMetadata of originalObjectMetadataCollection) {
|
||||
const standardObjectMetadata = standardObjectMetadataMap.get(
|
||||
originalObjectMetadata.nameSingular,
|
||||
);
|
||||
|
||||
if (!standardObjectMetadata && !originalObjectMetadata.isCustom) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const computedStandardObjectMetadata = computeStandardObject(
|
||||
standardObjectMetadata ?? {
|
||||
...originalObjectMetadata,
|
||||
fields: standardFieldMetadataCollection,
|
||||
},
|
||||
originalObjectMetadata,
|
||||
customObjectMetadataCollection,
|
||||
);
|
||||
|
||||
if (
|
||||
!originalObjectMetadata.isCustom &&
|
||||
!originalObjectMetadata.standardId
|
||||
) {
|
||||
updateObjectMetadataCollection.push({
|
||||
id: originalObjectMetadata.id,
|
||||
standardId: computedStandardObjectMetadata.standardId,
|
||||
});
|
||||
}
|
||||
|
||||
for (const fieldMetadata of originalObjectMetadata.fields) {
|
||||
const standardFieldMetadata =
|
||||
computedStandardObjectMetadata.fields.find(
|
||||
(field) => field.name === fieldMetadata.name && !field.isCustom,
|
||||
);
|
||||
|
||||
if (!standardFieldMetadata || fieldMetadata.standardId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
updateFieldMetadataCollection.push({
|
||||
id: fieldMetadata.id,
|
||||
standardId: standardFieldMetadata.standardId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await objectMetadataRepository.save(updateObjectMetadataCollection);
|
||||
|
||||
await fieldMetadataRepository.save(updateFieldMetadataCollection);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
this.logger.error('Error adding standard id to metadata', error);
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceSyncStorage } from 'src/engine/workspace-manager/workspace-sync-metadata/storage/workspace-sync.storage';
|
||||
import { WorkspaceMigrationEntity } from 'src/engine-metadata/workspace-migration/workspace-migration.entity';
|
||||
import { CommandLogger } from 'src/commands/command-logger';
|
||||
|
||||
@Injectable()
|
||||
export class SyncWorkspaceLoggerService {
|
||||
private readonly commandLogger = new CommandLogger(
|
||||
SyncWorkspaceLoggerService.name,
|
||||
);
|
||||
|
||||
constructor() {}
|
||||
|
||||
async saveLogs(
|
||||
workspaceId: string,
|
||||
storage: WorkspaceSyncStorage,
|
||||
workspaceMigrations: WorkspaceMigrationEntity[],
|
||||
) {
|
||||
// Create sub directory
|
||||
await this.commandLogger.createSubDirectory(workspaceId);
|
||||
|
||||
// Save workspace migrations
|
||||
await this.commandLogger.writeLog(
|
||||
`${workspaceId}/workspace-migrations`,
|
||||
workspaceMigrations,
|
||||
);
|
||||
|
||||
// Save object metadata create collection
|
||||
await this.commandLogger.writeLog(
|
||||
`${workspaceId}/object-metadata-create-collection`,
|
||||
storage.objectMetadataCreateCollection,
|
||||
);
|
||||
|
||||
// Save object metadata update collection
|
||||
await this.commandLogger.writeLog(
|
||||
`${workspaceId}/object-metadata-update-collection`,
|
||||
storage.objectMetadataUpdateCollection,
|
||||
);
|
||||
|
||||
// Save object metadata delete collection
|
||||
await this.commandLogger.writeLog(
|
||||
`${workspaceId}/object-metadata-delete-collection`,
|
||||
storage.objectMetadataDeleteCollection,
|
||||
);
|
||||
|
||||
// Save field metadata create collection
|
||||
await this.commandLogger.writeLog(
|
||||
`${workspaceId}/field-metadata-create-collection`,
|
||||
storage.fieldMetadataCreateCollection,
|
||||
);
|
||||
|
||||
// Save field metadata update collection
|
||||
await this.commandLogger.writeLog(
|
||||
`${workspaceId}/field-metadata-update-collection`,
|
||||
storage.fieldMetadataUpdateCollection,
|
||||
);
|
||||
|
||||
// Save field metadata delete collection
|
||||
await this.commandLogger.writeLog(
|
||||
`${workspaceId}/field-metadata-delete-collection`,
|
||||
storage.fieldMetadataDeleteCollection,
|
||||
);
|
||||
|
||||
// Save relation metadata create collection
|
||||
await this.commandLogger.writeLog(
|
||||
`${workspaceId}/relation-metadata-create-collection`,
|
||||
storage.relationMetadataCreateCollection,
|
||||
);
|
||||
|
||||
// Save relation metadata update collection
|
||||
await this.commandLogger.writeLog(
|
||||
`${workspaceId}/relation-metadata-update-collection`,
|
||||
storage.relationMetadataUpdateCollection,
|
||||
);
|
||||
|
||||
// Save relation metadata delete collection
|
||||
await this.commandLogger.writeLog(
|
||||
`${workspaceId}/relation-metadata-delete-collection`,
|
||||
storage.relationMetadataDeleteCollection,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,119 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { Command, CommandRunner, Option } from 'nest-commander';
|
||||
|
||||
import { DataSourceService } from 'src/engine-metadata/data-source/data-source.service';
|
||||
import { WorkspaceSyncMetadataService } from 'src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.service';
|
||||
import { WorkspaceHealthService } from 'src/engine/workspace-manager/workspace-health/workspace-health.service';
|
||||
import { WorkspaceService } from 'src/engine/modules/workspace/services/workspace.service';
|
||||
|
||||
import { SyncWorkspaceLoggerService } from './services/sync-workspace-logger.service';
|
||||
|
||||
// TODO: implement dry-run
|
||||
interface RunWorkspaceMigrationsOptions {
|
||||
workspaceId?: string;
|
||||
dryRun?: boolean;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
@Command({
|
||||
name: 'workspace:sync-metadata',
|
||||
description: 'Sync metadata',
|
||||
})
|
||||
export class SyncWorkspaceMetadataCommand extends CommandRunner {
|
||||
private readonly logger = new Logger(SyncWorkspaceMetadataCommand.name);
|
||||
|
||||
constructor(
|
||||
private readonly workspaceSyncMetadataService: WorkspaceSyncMetadataService,
|
||||
private readonly workspaceHealthService: WorkspaceHealthService,
|
||||
private readonly dataSourceService: DataSourceService,
|
||||
private readonly syncWorkspaceLoggerService: SyncWorkspaceLoggerService,
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(
|
||||
_passedParam: string[],
|
||||
options: RunWorkspaceMigrationsOptions,
|
||||
): Promise<void> {
|
||||
const workspaceIds = options.workspaceId
|
||||
? [options.workspaceId]
|
||||
: await this.workspaceService.getWorkspaceIds();
|
||||
|
||||
for (const workspaceId of workspaceIds) {
|
||||
const issues = await this.workspaceHealthService.healthCheck(workspaceId);
|
||||
|
||||
// Security: abort if there are issues.
|
||||
if (issues.length > 0) {
|
||||
if (!options.force) {
|
||||
this.logger.error(
|
||||
`Workspace contains ${issues.length} issues, aborting.`,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
'If you want to force the migration, use --force flag',
|
||||
);
|
||||
this.logger.log(
|
||||
'Please use `workspace:health` command to check issues and fix them before running this command.',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`Workspace contains ${issues.length} issues, sync has been forced.`,
|
||||
);
|
||||
}
|
||||
|
||||
const dataSourceMetadata =
|
||||
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const { storage, workspaceMigrations } =
|
||||
await this.workspaceSyncMetadataService.synchronize(
|
||||
{
|
||||
workspaceId,
|
||||
dataSourceId: dataSourceMetadata.id,
|
||||
},
|
||||
{ applyChanges: !options.dryRun },
|
||||
);
|
||||
|
||||
if (options.dryRun) {
|
||||
await this.syncWorkspaceLoggerService.saveLogs(
|
||||
workspaceId,
|
||||
storage,
|
||||
workspaceMigrations,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-w, --workspace-id [workspace_id]',
|
||||
description: 'workspace id',
|
||||
required: false,
|
||||
})
|
||||
parseWorkspaceId(value: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-d, --dry-run',
|
||||
description: 'Dry run without applying changes',
|
||||
required: false,
|
||||
})
|
||||
dryRun(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-f, --force',
|
||||
description: 'Force migration',
|
||||
required: false,
|
||||
})
|
||||
force(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { DataSourceModule } from 'src/engine-metadata/data-source/data-source.module';
|
||||
import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module';
|
||||
import { WorkspaceHealthModule } from 'src/engine/workspace-manager/workspace-health/workspace-health.module';
|
||||
import { WorkspaceModule } from 'src/engine/modules/workspace/workspace.module';
|
||||
import { AddStandardIdCommand } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/add-standard-id.command';
|
||||
|
||||
import { SyncWorkspaceMetadataCommand } from './sync-workspace-metadata.command';
|
||||
|
||||
import { SyncWorkspaceLoggerService } from './services/sync-workspace-logger.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
WorkspaceSyncMetadataModule,
|
||||
WorkspaceHealthModule,
|
||||
WorkspaceModule,
|
||||
DataSourceModule,
|
||||
],
|
||||
providers: [
|
||||
SyncWorkspaceMetadataCommand,
|
||||
AddStandardIdCommand,
|
||||
SyncWorkspaceLoggerService,
|
||||
],
|
||||
})
|
||||
export class WorkspaceSyncMetadataCommandsModule {}
|
||||
@ -0,0 +1,116 @@
|
||||
import { ComparatorAction } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface';
|
||||
|
||||
import { WorkspaceFieldComparator } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-field.comparator';
|
||||
|
||||
describe('WorkspaceFieldComparator', () => {
|
||||
let comparator: WorkspaceFieldComparator;
|
||||
|
||||
beforeEach(() => {
|
||||
// Initialize the comparator before each test
|
||||
comparator = new WorkspaceFieldComparator();
|
||||
});
|
||||
|
||||
function createMockFieldMetadata(values: any) {
|
||||
return {
|
||||
workspaceId: 'some-workspace-id',
|
||||
type: 'TEXT',
|
||||
name: 'DefaultFieldName',
|
||||
label: 'Default Field Label',
|
||||
targetColumnMap: 'default_column',
|
||||
defaultValue: null,
|
||||
description: 'Default description',
|
||||
isCustom: false,
|
||||
isSystem: false,
|
||||
isNullable: true,
|
||||
...values,
|
||||
};
|
||||
}
|
||||
|
||||
it('should generate CREATE action for new fields', () => {
|
||||
const original = { fields: [] } as any;
|
||||
const standard = {
|
||||
fields: [
|
||||
createMockFieldMetadata({
|
||||
standardId: 'no-field-1',
|
||||
name: 'New Field',
|
||||
}),
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = comparator.compare(original, standard);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
action: ComparatorAction.CREATE,
|
||||
object: expect.objectContaining(standard.fields[0]),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should generate UPDATE action for modified fields', () => {
|
||||
const original = {
|
||||
fields: [
|
||||
createMockFieldMetadata({
|
||||
standardId: '1',
|
||||
id: '1',
|
||||
isNullable: true,
|
||||
}),
|
||||
],
|
||||
} as any;
|
||||
const standard = {
|
||||
fields: [
|
||||
createMockFieldMetadata({
|
||||
standardId: '1',
|
||||
isNullable: false,
|
||||
}),
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = comparator.compare(original, standard);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
action: ComparatorAction.UPDATE,
|
||||
object: expect.objectContaining({ id: '1', isNullable: false }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should generate DELETE action for removed fields', () => {
|
||||
const original = {
|
||||
fields: [
|
||||
createMockFieldMetadata({
|
||||
standardId: '1',
|
||||
id: '1',
|
||||
name: 'Removed Field',
|
||||
isActive: true,
|
||||
}),
|
||||
],
|
||||
} as any;
|
||||
const standard = { fields: [] } as any;
|
||||
|
||||
const result = comparator.compare(original, standard);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
action: ComparatorAction.DELETE,
|
||||
object: expect.objectContaining({ name: 'Removed Field' }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not generate any action for identical fields', () => {
|
||||
const original = {
|
||||
fields: [
|
||||
createMockFieldMetadata({ standardId: '1', id: '1', isActive: true }),
|
||||
],
|
||||
} as any;
|
||||
const standard = {
|
||||
fields: [createMockFieldMetadata({ standardId: '1' })],
|
||||
} as any;
|
||||
|
||||
const result = comparator.compare(original, standard);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,82 @@
|
||||
import { ComparatorAction } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface';
|
||||
|
||||
import { WorkspaceObjectComparator } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-object.comparator';
|
||||
|
||||
describe('WorkspaceObjectComparator', () => {
|
||||
let comparator: WorkspaceObjectComparator;
|
||||
|
||||
beforeEach(() => {
|
||||
// Initialize the comparator before each test
|
||||
comparator = new WorkspaceObjectComparator();
|
||||
});
|
||||
|
||||
function createMockObjectMetadata(values: any) {
|
||||
return {
|
||||
nameSingular: 'TestObject',
|
||||
namePlural: 'TestObjects',
|
||||
labelSingular: 'Test Object',
|
||||
labelPlural: 'Test Objects',
|
||||
...values,
|
||||
};
|
||||
}
|
||||
|
||||
it('should generate CREATE action for new objects', () => {
|
||||
const standardObjectMetadata = createMockObjectMetadata({
|
||||
standardId: 'no-object-1',
|
||||
description: 'A standard object',
|
||||
});
|
||||
|
||||
const result = comparator.compare(undefined, standardObjectMetadata);
|
||||
|
||||
expect(result).toEqual({
|
||||
action: ComparatorAction.CREATE,
|
||||
object: standardObjectMetadata,
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate UPDATE action for objects with differences', () => {
|
||||
const originalObjectMetadata = createMockObjectMetadata({
|
||||
standardId: '1',
|
||||
id: '1',
|
||||
description: 'Original description',
|
||||
});
|
||||
const standardObjectMetadata = createMockObjectMetadata({
|
||||
standardId: '1',
|
||||
description: 'Updated description',
|
||||
});
|
||||
|
||||
const result = comparator.compare(
|
||||
originalObjectMetadata,
|
||||
standardObjectMetadata,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
action: ComparatorAction.UPDATE,
|
||||
object: expect.objectContaining({
|
||||
id: '1',
|
||||
description: 'Updated description',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate SKIP action for identical objects', () => {
|
||||
const originalObjectMetadata = createMockObjectMetadata({
|
||||
standardId: '1',
|
||||
id: '1',
|
||||
description: 'Same description',
|
||||
});
|
||||
const standardObjectMetadata = createMockObjectMetadata({
|
||||
standardId: '1',
|
||||
description: 'Same description',
|
||||
});
|
||||
|
||||
const result = comparator.compare(
|
||||
originalObjectMetadata,
|
||||
standardObjectMetadata,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
action: ComparatorAction.SKIP,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,82 @@
|
||||
import { ComparatorAction } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface';
|
||||
|
||||
import { WorkspaceRelationComparator } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-relation.comparator';
|
||||
|
||||
describe('WorkspaceRelationComparator', () => {
|
||||
let comparator: WorkspaceRelationComparator;
|
||||
|
||||
beforeEach(() => {
|
||||
comparator = new WorkspaceRelationComparator();
|
||||
});
|
||||
|
||||
function createMockRelationMetadata(values: any) {
|
||||
return {
|
||||
fromObjectMetadataId: 'object-1',
|
||||
fromFieldMetadataId: 'field-1',
|
||||
...values,
|
||||
};
|
||||
}
|
||||
|
||||
it('should generate CREATE action for new relations', () => {
|
||||
const original = [];
|
||||
const standard = [createMockRelationMetadata({})];
|
||||
|
||||
const result = comparator.compare(original, standard);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
action: ComparatorAction.CREATE,
|
||||
object: expect.objectContaining({
|
||||
fromObjectMetadataId: 'object-1',
|
||||
fromFieldMetadataId: 'field-1',
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should generate DELETE action for removed relations', () => {
|
||||
const original = [createMockRelationMetadata({ id: '1' })];
|
||||
const standard = [];
|
||||
|
||||
const result = comparator.compare(original, standard);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
action: ComparatorAction.DELETE,
|
||||
object: expect.objectContaining({ id: '1' }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should generate UPDATE action for changed relations', () => {
|
||||
const original = [
|
||||
createMockRelationMetadata({ onDeleteAction: 'CASCADE' }),
|
||||
];
|
||||
const standard = [
|
||||
createMockRelationMetadata({ onDeleteAction: 'SET_NULL' }),
|
||||
];
|
||||
|
||||
const result = comparator.compare(original, standard);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
action: ComparatorAction.UPDATE,
|
||||
object: expect.objectContaining({
|
||||
fromObjectMetadataId: 'object-1',
|
||||
fromFieldMetadataId: 'field-1',
|
||||
onDeleteAction: 'SET_NULL',
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not generate any action for identical relations', () => {
|
||||
const relation = createMockRelationMetadata({});
|
||||
const original = [{ id: '1', ...relation }];
|
||||
const standard = [relation];
|
||||
|
||||
const result = comparator.compare(original, standard);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,9 @@
|
||||
import { WorkspaceFieldComparator } from './workspace-field.comparator';
|
||||
import { WorkspaceObjectComparator } from './workspace-object.comparator';
|
||||
import { WorkspaceRelationComparator } from './workspace-relation.comparator';
|
||||
|
||||
export const workspaceSyncMetadataComparators = [
|
||||
WorkspaceFieldComparator,
|
||||
WorkspaceObjectComparator,
|
||||
WorkspaceRelationComparator,
|
||||
];
|
||||
@ -0,0 +1,52 @@
|
||||
import { orderObjectProperties } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/utils/order-object-properties.util';
|
||||
|
||||
describe('orderObjectProperties', () => {
|
||||
it('orders simple object properties', () => {
|
||||
const input = { b: 2, a: 1 };
|
||||
const expected = { a: 1, b: 2 };
|
||||
|
||||
expect(orderObjectProperties(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('orders nested object properties', () => {
|
||||
const input = { b: { d: 4, c: 3 }, a: 1 };
|
||||
const expected = { a: 1, b: { c: 3, d: 4 } };
|
||||
|
||||
expect(orderObjectProperties(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('orders properties in an array of objects', () => {
|
||||
const input = [
|
||||
{ b: 2, a: 1 },
|
||||
{ d: 4, c: 3 },
|
||||
];
|
||||
const expected = [
|
||||
{ a: 1, b: 2 },
|
||||
{ c: 3, d: 4 },
|
||||
];
|
||||
|
||||
expect(orderObjectProperties(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('handles nested arrays within objects', () => {
|
||||
const input = { b: [{ d: 4, c: 3 }], a: 1 };
|
||||
const expected = { a: 1, b: [{ c: 3, d: 4 }] };
|
||||
|
||||
expect(orderObjectProperties(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('handles complex nested structures', () => {
|
||||
const input = {
|
||||
c: 3,
|
||||
a: { f: [{ j: 10, i: 9 }, 8], e: 5 },
|
||||
b: [7, { h: 6, g: 4 }],
|
||||
};
|
||||
const expected = {
|
||||
a: { e: 5, f: [{ i: 9, j: 10 }, 8] },
|
||||
b: [7, { g: 4, h: 6 }],
|
||||
c: 3,
|
||||
};
|
||||
|
||||
expect(orderObjectProperties(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,59 @@
|
||||
import { transformMetadataForComparison } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util'; // Adjust the import path as necessary
|
||||
|
||||
describe('transformMetadataForComparison', () => {
|
||||
// Test for a single object
|
||||
it('transforms a single object correctly with nested objects', () => {
|
||||
const input = { name: 'Test', details: { a: 1, nested: { b: 2 } } };
|
||||
const result = transformMetadataForComparison(input, {
|
||||
propertiesToStringify: ['details'],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'Test',
|
||||
details: '{"a":1,"nested":{"b":2}}',
|
||||
});
|
||||
});
|
||||
|
||||
// Test for an array of objects
|
||||
it('transforms an array of objects correctly, ignoring and stringifying multiple properties', () => {
|
||||
const input = [
|
||||
{ name: 'Test1', value: { a: 1 }, ignored: 'ignoreMe' },
|
||||
{ name: 'Test2', value: { c: 3 }, extra: 'keepMe' },
|
||||
];
|
||||
const result = transformMetadataForComparison(input, {
|
||||
shouldIgnoreProperty: (property) => ['ignored'].includes(property),
|
||||
propertiesToStringify: ['value'],
|
||||
keyFactory: (datum) => datum.name,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
Test1: { name: 'Test1', value: '{"a":1}' },
|
||||
Test2: { name: 'Test2', value: '{"c":3}', extra: 'keepMe' },
|
||||
});
|
||||
});
|
||||
|
||||
// Test with a custom keyFactory function
|
||||
it('uses a custom keyFactory function to generate keys', () => {
|
||||
const input = [{ id: 123, name: 'Test' }];
|
||||
const result = transformMetadataForComparison(input, {
|
||||
keyFactory: (datum) => `key-${datum.id}`,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('key-123');
|
||||
expect(result['key-123']).toEqual({ id: 123, name: 'Test' });
|
||||
});
|
||||
|
||||
// Test with an empty array
|
||||
it('handles an empty array gracefully', () => {
|
||||
const result = transformMetadataForComparison([], {});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
// Test with an empty object
|
||||
it('handles an empty object gracefully', () => {
|
||||
const result = transformMetadataForComparison({}, {});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,21 @@
|
||||
export function orderObjectProperties<T extends object>(data: T[]): T[];
|
||||
|
||||
export function orderObjectProperties<T extends object>(data: T): T;
|
||||
|
||||
export function orderObjectProperties<T extends Array<any> | object>(
|
||||
data: T,
|
||||
): T {
|
||||
if (Array.isArray(data)) {
|
||||
return data.map(orderObjectProperties) as T;
|
||||
}
|
||||
|
||||
if (data !== null && typeof data === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(data)
|
||||
.sort()
|
||||
.map(([key, value]) => [key, orderObjectProperties(value)]),
|
||||
) as T;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
import { orderObjectProperties } from './order-object-properties.util';
|
||||
|
||||
type TransformToString<T, Keys extends keyof T> = {
|
||||
[P in keyof T]: P extends Keys ? string : T[P];
|
||||
};
|
||||
|
||||
// Overload for an array of T
|
||||
export function transformMetadataForComparison<T, Keys extends keyof T>(
|
||||
fieldMetadataCollection: T[],
|
||||
options: {
|
||||
shouldIgnoreProperty?: (property: string, originalMetadata?: T) => boolean;
|
||||
propertiesToStringify?: readonly Keys[];
|
||||
keyFactory: (datum: T) => string;
|
||||
},
|
||||
): Record<string, TransformToString<T, Keys>>;
|
||||
|
||||
// Overload for a single T object
|
||||
export function transformMetadataForComparison<T, Keys extends keyof T>(
|
||||
fieldMetadataCollection: T,
|
||||
options: {
|
||||
shouldIgnoreProperty?: (property: string, originalMetadata?: T) => boolean;
|
||||
propertiesToStringify?: readonly Keys[];
|
||||
},
|
||||
): TransformToString<T, Keys>;
|
||||
|
||||
export function transformMetadataForComparison<T, Keys extends keyof T>(
|
||||
metadata: T[] | T,
|
||||
options: {
|
||||
shouldIgnoreProperty?: (property: string, originalMetadata?: T) => boolean;
|
||||
propertiesToStringify?: readonly Keys[];
|
||||
keyFactory?: (datum: T) => string;
|
||||
},
|
||||
): Record<string, TransformToString<T, Keys>> | TransformToString<T, Keys> {
|
||||
const propertiesToStringify = (options.propertiesToStringify ??
|
||||
[]) as readonly string[];
|
||||
|
||||
const transformProperties = (datum: T): TransformToString<T, Keys> => {
|
||||
const transformedField = {} as TransformToString<T, Keys>;
|
||||
|
||||
for (const property in datum) {
|
||||
if (
|
||||
options.shouldIgnoreProperty &&
|
||||
options.shouldIgnoreProperty(property, datum)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
propertiesToStringify.includes(property) &&
|
||||
datum[property] !== null &&
|
||||
typeof datum[property] === 'object'
|
||||
) {
|
||||
const orderedValue = orderObjectProperties(datum[property] as object);
|
||||
|
||||
transformedField[property as string] = JSON.stringify(
|
||||
orderedValue,
|
||||
) as T[Keys];
|
||||
} else {
|
||||
transformedField[property as string] = datum[property];
|
||||
}
|
||||
}
|
||||
|
||||
return transformedField;
|
||||
};
|
||||
|
||||
if (Array.isArray(metadata)) {
|
||||
return metadata.reduce<Record<string, TransformToString<T, Keys>>>(
|
||||
(acc, datum) => {
|
||||
const key = options.keyFactory?.(datum);
|
||||
|
||||
if (!key) {
|
||||
throw new Error('keyFactory must be implemented');
|
||||
}
|
||||
|
||||
acc[key] = transformProperties(datum);
|
||||
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
} else {
|
||||
return transformProperties(metadata);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,209 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import diff from 'microdiff';
|
||||
|
||||
import {
|
||||
ComparatorAction,
|
||||
FieldComparatorResult,
|
||||
} from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface';
|
||||
import { ComputedPartialFieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-field-metadata.interface';
|
||||
import { ComputedPartialObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-object-metadata.interface';
|
||||
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { transformMetadataForComparison } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util';
|
||||
import {
|
||||
FieldMetadataEntity,
|
||||
FieldMetadataType,
|
||||
} from 'src/engine-metadata/field-metadata/field-metadata.entity';
|
||||
|
||||
const commonFieldPropertiesToIgnore = [
|
||||
'id',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'objectMetadataId',
|
||||
'isActive',
|
||||
'options',
|
||||
];
|
||||
|
||||
const fieldPropertiesToStringify = ['targetColumnMap', 'defaultValue'] as const;
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceFieldComparator {
|
||||
constructor() {}
|
||||
|
||||
public compare(
|
||||
originalObjectMetadata: ObjectMetadataEntity,
|
||||
standardObjectMetadata: ComputedPartialObjectMetadata,
|
||||
): FieldComparatorResult[] {
|
||||
const result: FieldComparatorResult[] = [];
|
||||
const fieldPropertiesToUpdateMap: Record<
|
||||
string,
|
||||
Partial<ComputedPartialFieldMetadata>
|
||||
> = {};
|
||||
|
||||
// Double security to only compare non-custom fields
|
||||
const filteredOriginalFieldCollection =
|
||||
originalObjectMetadata.fields.filter((field) => !field.isCustom);
|
||||
const originalFieldMetadataMap = transformMetadataForComparison(
|
||||
filteredOriginalFieldCollection,
|
||||
{
|
||||
shouldIgnoreProperty: (property, originalMetadata) => {
|
||||
if (commonFieldPropertiesToIgnore.includes(property)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
originalMetadata &&
|
||||
property === 'defaultValue' &&
|
||||
originalMetadata.type === FieldMetadataType.SELECT
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
propertiesToStringify: fieldPropertiesToStringify,
|
||||
keyFactory(datum) {
|
||||
// Happen when the field is custom
|
||||
return datum.standardId || datum.name;
|
||||
},
|
||||
},
|
||||
);
|
||||
const standardFieldMetadataMap = transformMetadataForComparison(
|
||||
standardObjectMetadata.fields,
|
||||
{
|
||||
shouldIgnoreProperty: (property, originalMetadata) => {
|
||||
if (['options', 'gate'].includes(property)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
originalMetadata &&
|
||||
property === 'defaultValue' &&
|
||||
originalMetadata.type === FieldMetadataType.SELECT
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
propertiesToStringify: fieldPropertiesToStringify,
|
||||
keyFactory(datum) {
|
||||
// Happen when the field is custom
|
||||
return datum.standardId || datum.name;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Compare fields
|
||||
const fieldMetadataDifference = diff(
|
||||
originalFieldMetadataMap,
|
||||
standardFieldMetadataMap,
|
||||
);
|
||||
|
||||
for (const difference of fieldMetadataDifference) {
|
||||
const fieldName = difference.path[0];
|
||||
const findField = (
|
||||
field: ComputedPartialFieldMetadata | FieldMetadataEntity,
|
||||
) => {
|
||||
if (field.isCustom) {
|
||||
return field.name === fieldName;
|
||||
}
|
||||
|
||||
return field.standardId === fieldName;
|
||||
};
|
||||
// Object shouldn't have thousands of fields, so we can use find here
|
||||
const standardFieldMetadata =
|
||||
standardObjectMetadata.fields.find(findField);
|
||||
const originalFieldMetadata =
|
||||
originalObjectMetadata.fields.find(findField);
|
||||
|
||||
switch (difference.type) {
|
||||
case 'CREATE': {
|
||||
if (!standardFieldMetadata) {
|
||||
throw new Error(
|
||||
`Field ${fieldName} not found in standardObjectMetadata`,
|
||||
);
|
||||
}
|
||||
|
||||
result.push({
|
||||
action: ComparatorAction.CREATE,
|
||||
object: {
|
||||
...standardFieldMetadata,
|
||||
objectMetadataId: originalObjectMetadata.id,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'CHANGE': {
|
||||
if (!originalFieldMetadata) {
|
||||
throw new Error(
|
||||
`Field ${fieldName} not found in originalObjectMetadata`,
|
||||
);
|
||||
}
|
||||
|
||||
const id = originalFieldMetadata.id;
|
||||
const property = difference.path[difference.path.length - 1];
|
||||
|
||||
// If the old value and the new value are both null, skip
|
||||
// Database is storing null, and we can get undefined here
|
||||
if (
|
||||
difference.oldValue === null &&
|
||||
(difference.value === null || difference.value === undefined)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (typeof property !== 'string') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!fieldPropertiesToUpdateMap[id]) {
|
||||
fieldPropertiesToUpdateMap[id] = {};
|
||||
}
|
||||
|
||||
// If the property is a stringified JSON, parse it
|
||||
if (
|
||||
(fieldPropertiesToStringify as readonly string[]).includes(property)
|
||||
) {
|
||||
fieldPropertiesToUpdateMap[id][property] = JSON.parse(
|
||||
difference.value,
|
||||
);
|
||||
} else {
|
||||
fieldPropertiesToUpdateMap[id][property] = difference.value;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'REMOVE': {
|
||||
if (!originalFieldMetadata) {
|
||||
throw new Error(
|
||||
`Field ${fieldName} not found in originalObjectMetadata`,
|
||||
);
|
||||
}
|
||||
|
||||
if (difference.path.length === 1) {
|
||||
result.push({
|
||||
action: ComparatorAction.DELETE,
|
||||
object: originalFieldMetadata,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, fieldPropertiesToUpdate] of Object.entries(
|
||||
fieldPropertiesToUpdateMap,
|
||||
)) {
|
||||
result.push({
|
||||
action: ComparatorAction.UPDATE,
|
||||
object: {
|
||||
id,
|
||||
...fieldPropertiesToUpdate,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import diff from 'microdiff';
|
||||
import omit from 'lodash.omit';
|
||||
|
||||
import {
|
||||
ComparatorAction,
|
||||
ObjectComparatorResult,
|
||||
} from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface';
|
||||
import { ComputedPartialObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-object-metadata.interface';
|
||||
|
||||
import { transformMetadataForComparison } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util';
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
|
||||
const objectPropertiesToIgnore = [
|
||||
'id',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'labelIdentifierFieldMetadataId',
|
||||
'imageIdentifierFieldMetadataId',
|
||||
'isActive',
|
||||
'fields',
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceObjectComparator {
|
||||
constructor() {}
|
||||
|
||||
public compare(
|
||||
originalObjectMetadata: ObjectMetadataEntity | undefined,
|
||||
standardObjectMetadata: ComputedPartialObjectMetadata,
|
||||
): ObjectComparatorResult {
|
||||
// If the object doesn't exist in the original metadata, we need to create it
|
||||
if (!originalObjectMetadata) {
|
||||
return {
|
||||
action: ComparatorAction.CREATE,
|
||||
object: standardObjectMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
const objectPropertiesToUpdate: Partial<ComputedPartialObjectMetadata> = {};
|
||||
|
||||
// Only compare properties that are not ignored
|
||||
const partialOriginalObjectMetadata = transformMetadataForComparison(
|
||||
originalObjectMetadata,
|
||||
{
|
||||
shouldIgnoreProperty: (property) =>
|
||||
objectPropertiesToIgnore.includes(property),
|
||||
},
|
||||
);
|
||||
|
||||
// Compare objects
|
||||
const objectMetadataDifference = diff(
|
||||
partialOriginalObjectMetadata,
|
||||
omit(standardObjectMetadata, 'fields'),
|
||||
);
|
||||
|
||||
// Loop through the differences and create an object with the properties to update
|
||||
for (const difference of objectMetadataDifference) {
|
||||
// We only handle CHANGE here as REMOVE and CREATE are handled earlier.
|
||||
if (difference.type === 'CHANGE') {
|
||||
const property = difference.path[0];
|
||||
|
||||
objectPropertiesToUpdate[property] = difference.value;
|
||||
}
|
||||
}
|
||||
|
||||
// If there are no properties to update, the objects are equal
|
||||
if (Object.keys(objectPropertiesToUpdate).length === 0) {
|
||||
return {
|
||||
action: ComparatorAction.SKIP,
|
||||
};
|
||||
}
|
||||
|
||||
// If there are properties to update, we need to update the object
|
||||
return {
|
||||
action: ComparatorAction.UPDATE,
|
||||
object: {
|
||||
id: originalObjectMetadata.id,
|
||||
...objectPropertiesToUpdate,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import diff from 'microdiff';
|
||||
|
||||
import {
|
||||
ComparatorAction,
|
||||
RelationComparatorResult,
|
||||
} from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface';
|
||||
|
||||
import { RelationMetadataEntity } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
|
||||
import { transformMetadataForComparison } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util';
|
||||
|
||||
const relationPropertiesToIgnore = ['createdAt', 'updatedAt'];
|
||||
const relationPropertiesToUpdate = ['onDeleteAction'];
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceRelationComparator {
|
||||
constructor() {}
|
||||
|
||||
compare(
|
||||
originalRelationMetadataCollection: RelationMetadataEntity[],
|
||||
standardRelationMetadataCollection: Partial<RelationMetadataEntity>[],
|
||||
): RelationComparatorResult[] {
|
||||
const results: RelationComparatorResult[] = [];
|
||||
|
||||
// Create a map of standard relations
|
||||
const standardRelationMetadataMap = transformMetadataForComparison(
|
||||
standardRelationMetadataCollection,
|
||||
{
|
||||
keyFactory(relationMetadata) {
|
||||
return `${relationMetadata.fromObjectMetadataId}->${relationMetadata.fromFieldMetadataId}`;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Create a filtered map of original relations
|
||||
// We filter out 'id' later because we need it to remove the relation from DB
|
||||
const originalRelationMetadataMap = transformMetadataForComparison(
|
||||
originalRelationMetadataCollection,
|
||||
{
|
||||
shouldIgnoreProperty: (property) =>
|
||||
relationPropertiesToIgnore.includes(property),
|
||||
keyFactory(relationMetadata) {
|
||||
return `${relationMetadata.fromObjectMetadataId}->${relationMetadata.fromFieldMetadataId}`;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Compare relations
|
||||
const relationMetadataDifference = diff(
|
||||
originalRelationMetadataMap,
|
||||
standardRelationMetadataMap,
|
||||
);
|
||||
|
||||
for (const difference of relationMetadataDifference) {
|
||||
switch (difference.type) {
|
||||
case 'CREATE':
|
||||
results.push({
|
||||
action: ComparatorAction.CREATE,
|
||||
object: difference.value,
|
||||
});
|
||||
break;
|
||||
case 'REMOVE':
|
||||
if (difference.path[difference.path.length - 1] !== 'id') {
|
||||
results.push({
|
||||
action: ComparatorAction.DELETE,
|
||||
object: difference.oldValue,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'CHANGE':
|
||||
const fieldName = difference.path[0];
|
||||
const property = difference.path[difference.path.length - 1];
|
||||
|
||||
if (!relationPropertiesToUpdate.includes(property as string)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalRelationMetadata =
|
||||
originalRelationMetadataMap[fieldName];
|
||||
|
||||
if (!originalRelationMetadata) {
|
||||
throw new Error(
|
||||
`Relation ${fieldName} not found in originalRelationMetadataMap`,
|
||||
);
|
||||
}
|
||||
|
||||
results.push({
|
||||
action: ComparatorAction.UPDATE,
|
||||
object: {
|
||||
id: originalRelationMetadata.id,
|
||||
fromObjectMetadataId:
|
||||
originalRelationMetadata.fromObjectMetadataId,
|
||||
fromFieldMetadataId: originalRelationMetadata.fromFieldMetadataId,
|
||||
toObjectMetadataId: originalRelationMetadata.toObjectMetadataId,
|
||||
toFieldMetadataId: originalRelationMetadata.toFieldMetadataId,
|
||||
workspaceId: originalRelationMetadata.workspaceId,
|
||||
...{
|
||||
[property]: difference.value,
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,295 @@
|
||||
/**
|
||||
* /!\ DO NOT EDIT THE IDS OF THIS FILE /!\
|
||||
* This file contains static ids for standard objects.
|
||||
* These ids are used to identify standard objects in the database and compare them even when renamed.
|
||||
* For readability keys can be edited but the values should not be changed.
|
||||
*/
|
||||
|
||||
export const activityTargetStandardFieldIds = {
|
||||
activity: '20202020-ca58-478c-a4f5-ae825671c30e',
|
||||
person: '20202020-4afd-4ae7-99c2-de57d795a93f',
|
||||
company: '20202020-7cc0-44a1-8068-f11171fdd02e',
|
||||
opportunity: '20202020-1fc2-4af1-8c91-7901ee0fd38b',
|
||||
custom: '20202020-7f21-442f-94be-32462281b1ca',
|
||||
};
|
||||
|
||||
export const activityStandardFieldIds = {
|
||||
title: '20202020-24a1-4d94-a071-617f3eeed7b0',
|
||||
body: '20202020-209b-440a-b2a8-043fa36a7d37',
|
||||
type: '20202020-0f2b-4aab-8827-ee5d3f07d993',
|
||||
reminderAt: '20202020-eb06-43e2-ba06-336be0e665a3',
|
||||
dueAt: '20202020-0336-4511-ba79-565b12801bd9',
|
||||
completedAt: '20202020-0f4d-4fca-9f2f-6309d9ecb85f',
|
||||
activityTargets: '20202020-7253-42cb-8586-8cf950e70b79',
|
||||
attachments: '20202020-5547-4197-bc2e-a07dfc4559ca',
|
||||
comments: '20202020-6b2e-4d29-bbd1-ecddb330e71a',
|
||||
author: '20202020-455f-44f2-8e89-1b0ef01cb7fb',
|
||||
assignee: '20202020-4259-48e4-9e77-6b92991906d5',
|
||||
};
|
||||
|
||||
export const apiKeyStandardFieldIds = {
|
||||
name: '20202020-72e6-4079-815b-436ce8a62f23',
|
||||
expiresAt: '20202020-659b-4241-af59-66515b8e7d40',
|
||||
revokedAt: '20202020-06ab-44b5-8faf-f6e407685001',
|
||||
};
|
||||
|
||||
export const attachmentStandardFieldIds = {
|
||||
name: '20202020-87a5-48f8-bbf7-ade388825a57',
|
||||
fullPath: '20202020-0d19-453d-8e8d-fbcda8ca3747',
|
||||
type: '20202020-a417-49b8-a40b-f6a7874caa0d',
|
||||
author: '20202020-6501-4ac5-a4ef-b2f8522ef6cd',
|
||||
activity: '20202020-b569-481b-a13f-9b94e47e54fe',
|
||||
person: '20202020-0158-4aa2-965c-5cdafe21ffa2',
|
||||
company: '20202020-ceab-4a28-b546-73b06b4c08d5',
|
||||
opportunity: '20202020-7374-499d-bea3-9354890755b5',
|
||||
custom: '20202020-302d-43b3-9aea-aa4f89282a9f',
|
||||
};
|
||||
|
||||
export const baseObjectStandardFieldIds = {
|
||||
id: '20202020-eda0-4cee-9577-3eb357e3c22b',
|
||||
createdAt: '20202020-66ac-4502-9975-e4d959c50311',
|
||||
updatedAt: '20202020-d767-4622-bdcf-d8a084834d86',
|
||||
};
|
||||
|
||||
export const blocklistStandardFieldIds = {
|
||||
handle: '20202020-eef3-44ed-aa32-4641d7fd4a3e',
|
||||
workspaceMember: '20202020-548d-4084-a947-fa20a39f7c06',
|
||||
};
|
||||
|
||||
export const calendarChannelEventAssociationStandardFieldIds = {
|
||||
calendarChannel: '20202020-93ee-4da4-8d58-0282c4a9cb7d',
|
||||
calendarEvent: '20202020-5aa5-437e-bb86-f42d457783e3',
|
||||
eventExternalId: '20202020-9ec8-48bb-b279-21d0734a75a1',
|
||||
};
|
||||
|
||||
export const calendarChannelStandardFieldIds = {
|
||||
connectedAccount: '20202020-95b1-4f44-82dc-61b042ae2414',
|
||||
handle: '20202020-1d08-420a-9aa7-22e0f298232d',
|
||||
visibility: '20202020-1b07-4796-9f01-d626bab7ca4d',
|
||||
isContactAutoCreationEnabled: '20202020-50fb-404b-ba28-369911a3793a',
|
||||
isSyncEnabled: '20202020-fe19-4818-8854-21f7b1b43395',
|
||||
syncCursor: '20202020-bac2-4852-a5cb-7a7898992b70',
|
||||
calendarChannelEventAssociations: '20202020-afb0-4a9f-979f-2d5087d71d09',
|
||||
};
|
||||
|
||||
export const calendarEventAttendeeStandardFieldIds = {
|
||||
calendarEvent: '20202020-fe3a-401c-b889-af4f4657a861',
|
||||
handle: '20202020-8692-4580-8210-9e09cbd031a7',
|
||||
displayName: '20202020-ee1e-4f9f-8ac1-5c0b2f69691e',
|
||||
isOrganizer: '20202020-66e7-4e00-9e06-d06c92650580',
|
||||
responseStatus: '20202020-cec0-4be8-8fba-c366abc23147',
|
||||
person: '20202020-5761-4842-8186-e1898ef93966',
|
||||
workspaceMember: '20202020-20e4-4591-93ed-aeb17a4dcbd2',
|
||||
};
|
||||
|
||||
export const calendarEventStandardFieldIds = {
|
||||
title: '20202020-080e-49d1-b21d-9702a7e2525c',
|
||||
isCanceled: '20202020-335b-4e04-b470-43b84b64863c',
|
||||
isFullDay: '20202020-551c-402c-bb6d-dfe9efe86bcb',
|
||||
startsAt: '20202020-2c57-4c75-93c5-2ac950a6ed67',
|
||||
endsAt: '20202020-2554-4ee1-a617-17907f6bab21',
|
||||
externalCreatedAt: '20202020-9f03-4058-a898-346c62181599',
|
||||
externalUpdatedAt: '20202020-b355-4c18-8825-ef42c8a5a755',
|
||||
description: '20202020-52c4-4266-a98f-e90af0b4d271',
|
||||
location: '20202020-641a-4ffe-960d-c3c186d95b17',
|
||||
iCalUID: '20202020-f24b-45f4-b6a3-d2f9fcb98714',
|
||||
conferenceSolution: '20202020-1c3f-4b5a-b526-5411a82179eb',
|
||||
conferenceUri: '20202020-0fc5-490a-871a-2df8a45ab46c',
|
||||
recurringEventExternalId: '20202020-4b96-43d0-8156-4c7a9717635c',
|
||||
calendarChannelEventAssociations: '20202020-bdf8-4572-a2cc-ecbb6bcc3a02',
|
||||
eventAttendees: '20202020-e07e-4ccb-88f5-6f3d00458eec',
|
||||
};
|
||||
|
||||
export const commentStandardFieldIds = {
|
||||
body: '20202020-d5eb-49d2-b3e0-1ed04145ebb7',
|
||||
author: '20202020-2ab1-427e-a981-cf089de3a9bd',
|
||||
activity: '20202020-c8d9-4c30-a35e-dc7f44388070',
|
||||
};
|
||||
|
||||
export const companyStandardFieldIds = {
|
||||
name: '20202020-4d99-4e2e-a84c-4a27837b1ece',
|
||||
domainName: '20202020-0c28-43d8-8ba5-3659924d3489',
|
||||
address: '20202020-a82a-4ee2-96cc-a18a3259d953',
|
||||
employees: '20202020-8965-464a-8a75-74bafc152a0b',
|
||||
linkedinLink: '20202020-ebeb-4beb-b9ad-6848036fb451',
|
||||
xLink: '20202020-6f64-4fd9-9580-9c1991c7d8c3',
|
||||
annualRecurringRevenue: '20202020-602a-495c-9776-f5d5b11d227b',
|
||||
idealCustomerProfile: '20202020-ba6b-438a-8213-2c5ba28d76a2',
|
||||
position: '20202020-9b4e-462b-991d-a0ee33326454',
|
||||
people: '20202020-3213-4ddf-9494-6422bcff8d7c',
|
||||
accountOwner: '20202020-95b8-4e10-9881-edb5d4765f9d',
|
||||
activityTargets: '20202020-c2a5-4c9b-9d9a-582bcd57fbc8',
|
||||
opportunities: '20202020-add3-4658-8e23-d70dccb6d0ec',
|
||||
favorites: '20202020-4d1d-41ac-b13b-621631298d55',
|
||||
attachments: '20202020-c1b5-4120-b0f0-987ca401ed53',
|
||||
};
|
||||
|
||||
export const connectedAccountStandardFieldIds = {
|
||||
handle: '20202020-c804-4a50-bb05-b3a9e24f1dec',
|
||||
provider: '20202020-ebb0-4516-befc-a9e95935efd5',
|
||||
accessToken: '20202020-707b-4a0a-8753-2ad42efe1e29',
|
||||
refreshToken: '20202020-532d-48bd-80a5-c4be6e7f6e49',
|
||||
accountOwner: '20202020-3517-4896-afac-b1d0aa362af6',
|
||||
lastSyncHistoryId: '20202020-115c-4a87-b50f-ac4367a971b9',
|
||||
messageChannels: '20202020-24f7-4362-8468-042204d1e445',
|
||||
calendarChannels: '20202020-af4a-47bb-99ec-51911c1d3977',
|
||||
};
|
||||
|
||||
export const favoriteStandardFieldIds = {
|
||||
position: '20202020-dd26-42c6-8c3c-2a7598c204f6',
|
||||
workspaceMember: '20202020-ce63-49cb-9676-fdc0c45892cd',
|
||||
person: '20202020-c428-4f40-b6f3-86091511c41c',
|
||||
company: '20202020-cff5-4682-8bf9-069169e08279',
|
||||
opportunity: '20202020-dabc-48e1-8318-2781a2b32aa2',
|
||||
custom: '20202020-855a-4bc8-9861-79deef37011f',
|
||||
};
|
||||
|
||||
export const messageChannelMessageAssociationStandardFieldIds = {
|
||||
messageChannel: '20202020-b658-408f-bd46-3bd2d15d7e52',
|
||||
message: '20202020-da5d-4ac5-8743-342ab0a0336b',
|
||||
messageExternalId: '20202020-37d6-438f-b6fd-6503596c8f34',
|
||||
messageThread: '20202020-fac8-42a8-94dd-44dbc920ae16',
|
||||
messageThreadExternalId: '20202020-35fb-421e-afa0-0b8e8f7f9018',
|
||||
};
|
||||
|
||||
export const messageChannelStandardFieldIds = {
|
||||
visibility: '20202020-6a6b-4532-9767-cbc61b469453',
|
||||
handle: '20202020-2c96-43c3-93e3-ed6b1acb69bc',
|
||||
connectedAccount: '20202020-49a2-44a4-b470-282c0440d15d',
|
||||
type: '20202020-ae95-42d9-a3f1-797a2ea22122',
|
||||
isContactAutoCreationEnabled: '20202020-fabd-4f14-b7c6-3310f6d132c6',
|
||||
messageChannelMessageAssociations: '20202020-49b8-4766-88fd-75f1e21b3d5f',
|
||||
};
|
||||
|
||||
export const messageParticipantStandardFieldIds = {
|
||||
message: '20202020-985b-429a-9db9-9e55f4898a2a',
|
||||
role: '20202020-65d1-42f4-8729-c9ec1f52aecd',
|
||||
handle: '20202020-2456-464e-b422-b965a4db4a0b',
|
||||
displayName: '20202020-36dd-4a4f-ac02-228425be9fac',
|
||||
person: '20202020-249d-4e0f-82cd-1b9df5cd3da2',
|
||||
workspaceMember: '20202020-77a7-4845-99ed-1bcbb478be6f',
|
||||
};
|
||||
|
||||
export const messageThreadStandardFieldIds = {
|
||||
messages: '20202020-3115-404f-aade-e1154b28e35a',
|
||||
messageChannelMessageAssociations: '20202020-314e-40a4-906d-a5d5d6c285f6',
|
||||
};
|
||||
|
||||
export const messageStandardFieldIds = {
|
||||
headerMessageId: '20202020-72b5-416d-aed8-b55609067d01',
|
||||
messageThread: '20202020-30f2-4ccd-9f5c-e41bb9d26214',
|
||||
direction: '20202020-0203-4118-8e2a-05b9bdae6dab',
|
||||
subject: '20202020-52d1-4036-b9ae-84bd722bb37a',
|
||||
text: '20202020-d2ee-4e7e-89de-9a0a9044a143',
|
||||
receivedAt: '20202020-140a-4a2a-9f86-f13b6a979afc',
|
||||
messageParticipants: '20202020-7cff-4a74-b63c-73228448cbd9',
|
||||
messageChannelMessageAssociations: '20202020-3cef-43a3-82c6-50e7cfbc9ae4',
|
||||
};
|
||||
|
||||
export const opportunityStandardFieldIds = {
|
||||
name: '20202020-8609-4f65-a2d9-44009eb422b5',
|
||||
amount: '20202020-583e-4642-8533-db761d5fa82f',
|
||||
closeDate: '20202020-527e-44d6-b1ac-c4158d307b97',
|
||||
probability: '20202020-69d4-45f3-9703-690b09fafcf0',
|
||||
stage: '20202020-6f76-477d-8551-28cd65b2b4b9',
|
||||
position: '20202020-806d-493a-bbc6-6313e62958e2',
|
||||
pipelineStep: '20202020-cc8c-4ae7-8d83-25c3addaec5a',
|
||||
pointOfContact: '20202020-8dfb-42fc-92b6-01afb759ed16',
|
||||
company: '20202020-cbac-457e-b565-adece5fc815f',
|
||||
favorites: '20202020-a1c2-4500-aaae-83ba8a0e827a',
|
||||
activityTargets: '20202020-220a-42d6-8261-b2102d6eab35',
|
||||
attachments: '20202020-87c7-4118-83d6-2f4031005209',
|
||||
};
|
||||
|
||||
export const personStandardFieldIds = {
|
||||
name: '20202020-3875-44d5-8c33-a6239011cab8',
|
||||
email: '20202020-a740-42bb-8849-8980fb3f12e1',
|
||||
linkedinLink: '20202020-f1af-48f7-893b-2007a73dd508',
|
||||
xLink: '20202020-8fc2-487c-b84a-55a99b145cfd',
|
||||
jobTitle: '20202020-b0d0-415a-bef9-640a26dacd9b',
|
||||
phone: '20202020-4564-4b8b-a09f-05445f2e0bce',
|
||||
city: '20202020-5243-4ffb-afc5-2c675da41346',
|
||||
avatarUrl: '20202020-b8a6-40df-961c-373dc5d2ec21',
|
||||
position: '20202020-fcd5-4231-aff5-fff583eaa0b1',
|
||||
company: '20202020-e2f3-448e-b34c-2d625f0025fd',
|
||||
pointOfContactForOpportunities: '20202020-911b-4a7d-b67b-918aa9a5b33a',
|
||||
activityTargets: '20202020-dee7-4b7f-b50a-1f50bd3be452',
|
||||
favorites: '20202020-4073-4117-9cf1-203bcdc91cbd',
|
||||
attachments: '20202020-cd97-451f-87fa-bcb789bdbf3a',
|
||||
messageParticipants: '20202020-498e-4c61-8158-fa04f0638334',
|
||||
calendarEventAttendees: '20202020-52ee-45e9-a702-b64b3753e3a9',
|
||||
};
|
||||
|
||||
export const pipelineStepStandardFieldIds = {
|
||||
name: '20202020-e10a-4119-9466-97873e86fa47',
|
||||
color: '20202020-4a09-4088-90b8-ce1c72730f43',
|
||||
position: '20202020-44e8-4520-af64-4a3cb37fa0c5',
|
||||
opportunities: '20202020-0442-482a-867f-6d8fd4145ed1',
|
||||
};
|
||||
|
||||
export const viewFieldStandardFieldIds = {
|
||||
fieldMetadataId: '20202020-135f-4c5b-b361-15f24870473c',
|
||||
isVisible: '20202020-e966-473c-9c18-f00d3347e0ba',
|
||||
size: '20202020-6fab-4bd0-ae72-20f3ee39d581',
|
||||
position: '20202020-19e5-4e4c-8c15-3a96d1fd0650',
|
||||
view: '20202020-e8da-4521-afab-d6d231f9fa18',
|
||||
};
|
||||
|
||||
export const viewFilterStandardFieldIds = {
|
||||
fieldMetadataId: '20202020-c9aa-4c94-8d0e-9592f5008fb0',
|
||||
operand: '20202020-bd23-48c4-9fab-29d1ffb80310',
|
||||
value: '20202020-1e55-4a1e-a1d2-fefb86a5fce5',
|
||||
displayValue: '20202020-1270-4ebf-9018-c0ec10d5038e',
|
||||
view: '20202020-4f5b-487e-829c-3d881c163611',
|
||||
};
|
||||
|
||||
export const viewSortStandardFieldIds = {
|
||||
fieldMetadataId: '20202020-8240-4657-aee4-7f0df8e94eca',
|
||||
direction: '20202020-b06e-4eb3-9b58-0a62e5d79836',
|
||||
view: '20202020-bd6c-422b-9167-5c105f2d02c8',
|
||||
};
|
||||
|
||||
export const viewStandardFieldIds = {
|
||||
name: '20202020-12c6-4f37-b588-c9b9bf57328d',
|
||||
objectMetadataId: '20202020-d6de-4fd5-84dd-47f9e730368b',
|
||||
type: '20202020-dd11-4607-9ec7-c57217262a7f',
|
||||
key: '20202020-298e-49fa-9f4a-7b416b110443',
|
||||
icon: '20202020-1f08-4fd9-929b-cbc07f317166',
|
||||
position: '20202020-e9db-4303-b271-e8250c450172',
|
||||
isCompact: '20202020-674e-4314-994d-05754ea7b22b',
|
||||
viewFields: '20202020-542b-4bdc-b177-b63175d48edf',
|
||||
viewFilters: '20202020-ff23-4154-b63c-21fb36cd0967',
|
||||
viewSorts: '20202020-891b-45c3-9fe1-80a75b4aa043',
|
||||
};
|
||||
|
||||
export const webhookStandardFieldIds = {
|
||||
targetUrl: '20202020-1229-45a8-8cf4-85c9172aae12',
|
||||
operation: '20202020-15b7-458e-bf30-74770a54410c',
|
||||
};
|
||||
|
||||
export const workspaceMemberStandardFieldIds = {
|
||||
name: '20202020-e914-43a6-9c26-3603c59065f4',
|
||||
colorScheme: '20202020-66bc-47f2-adac-f2ef7c598b63',
|
||||
locale: '20202020-402e-4695-b169-794fa015afbe',
|
||||
avatarUrl: '20202020-0ced-4c4f-a376-c98a966af3f6',
|
||||
userEmail: '20202020-4c5f-4e09-bebc-9e624e21ecf4',
|
||||
userId: '20202020-75a9-4dfc-bf25-2e4b43e89820',
|
||||
authoredActivities: '20202020-f139-4f13-a82f-a65a8d290a74',
|
||||
assignedActivities: '20202020-5c97-42b6-8ca9-c07622cbb33f',
|
||||
favorites: '20202020-f3c1-4faf-b343-cf7681038757',
|
||||
accountOwnerForCompanies: '20202020-dc29-4bd4-a3c1-29eafa324bee',
|
||||
authoredAttachments: '20202020-000f-4947-917f-1b09851024fe',
|
||||
authoredComments: '20202020-5536-4f59-b837-51c45ef43b05',
|
||||
connectedAccounts: '20202020-e322-4bde-a525-727079b4a100',
|
||||
messageParticipants: '20202020-8f99-48bc-a5eb-edd33dd54188',
|
||||
blocklist: '20202020-6cb2-4161-9f29-a4b7f1283859',
|
||||
calendarEventAttendees: '20202020-0dbc-4841-9ce1-3e793b5b3512',
|
||||
};
|
||||
|
||||
export const customObjectStandardFieldIds = {
|
||||
name: '20202020-ba07-4ffd-ba63-009491f5749c',
|
||||
position: '20202020-c2bd-4e16-bb9a-c8b0411bf49d',
|
||||
activityTargets: '20202020-7f42-40ae-b96c-c8a61acc83bf',
|
||||
favorites: '20202020-a4a7-4686-b296-1c6c3482ee21',
|
||||
attachments: '20202020-8d59-46ca-b7b2-73d167712134',
|
||||
};
|
||||
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* /!\ DO NOT EDIT THE IDS OF THIS FILE /!\
|
||||
* This file contains static ids for standard objects.
|
||||
* These ids are used to identify standard objects in the database and compare them even when renamed.
|
||||
* For readability keys can be edited but the values should not be changed.
|
||||
*/
|
||||
|
||||
export const standardObjectIds = {
|
||||
activityTarget: '20202020-2945-440e-8d1a-f84672d33d5e',
|
||||
activity: '20202020-39aa-4a89-843b-eb5f2a8b677f',
|
||||
apiKey: '20202020-4c00-401d-8cda-ec6a4c41cd7d',
|
||||
attachment: '20202020-bd3d-4c60-8dca-571c71d4447a',
|
||||
blocklist: '20202020-0408-4f38-b8a8-4d5e3e26e24d',
|
||||
calendarChannelEventAssociation: '20202020-491b-4aaa-9825-afd1bae6ae00',
|
||||
calendarChannel: '20202020-e8f2-40e1-a39c-c0e0039c5034',
|
||||
calendarEventAttendee: '20202020-a1c3-47a6-9732-27e5b1e8436d',
|
||||
calendarEvent: '20202020-8f1d-4eef-9f85-0d1965e27221',
|
||||
comment: '20202020-435f-4de9-89b5-97e32233bf5f',
|
||||
company: '20202020-b374-4779-a561-80086cb2e17f',
|
||||
connectedAccount: '20202020-977e-46b2-890b-c3002ddfd5c5',
|
||||
favorite: '20202020-ab56-4e05-92a3-e2414a499860',
|
||||
messageChannelMessageAssociation: '20202020-ad1e-4127-bccb-d83ae04d2ccb',
|
||||
messageChannel: '20202020-fe8c-40bc-a681-b80b771449b7',
|
||||
messageParticipant: '20202020-a433-4456-aa2d-fd9cb26b774a',
|
||||
messageThread: '20202020-849a-4c3e-84f5-a25a7d802271',
|
||||
message: '20202020-3f6b-4425-80ab-e468899ab4b2',
|
||||
opportunity: '20202020-9549-49dd-b2b2-883999db8938',
|
||||
person: '20202020-e674-48e5-a542-72570eee7213',
|
||||
pipelineStep: '20202020-f9a3-45f3-82e2-28952a8b19bf',
|
||||
viewField: '20202020-4d19-4655-95bf-b2a04cf206d4',
|
||||
viewFilter: '20202020-6fb6-4631-aded-b7d67e952ec8',
|
||||
viewSort: '20202020-e46a-47a8-939a-e5d911f83531',
|
||||
view: '20202020-722e-4739-8e2c-0c372d661f49',
|
||||
webhook: '20202020-be4d-4e08-811d-0fffcd13ffd4',
|
||||
workspaceMember: '20202020-3319-4234-a34c-82d5c0e881a6',
|
||||
};
|
||||
@ -0,0 +1,88 @@
|
||||
import { BaseCustomObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/base-custom-object-metadata.decorator';
|
||||
import { FieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/field-metadata.decorator';
|
||||
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
|
||||
import { IsNullable } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/is-nullable.decorator';
|
||||
import { IsSystem } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/is-system.decorator';
|
||||
import { BaseObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects/base.object-metadata';
|
||||
import {
|
||||
RelationMetadataType,
|
||||
RelationOnDeleteAction,
|
||||
} from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
|
||||
import { ActivityTargetObjectMetadata } from 'src/business/modules/activity/activity-target.object-metadata';
|
||||
import { RelationMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/decorators/relation-metadata.decorator';
|
||||
import { FavoriteObjectMetadata } from 'src/business/modules/favorite/favorite.object-metadata';
|
||||
import { AttachmentObjectMetadata } from 'src/business/modules/attachment/attachment.object-metadata';
|
||||
import { customObjectStandardFieldIds } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
|
||||
|
||||
@BaseCustomObjectMetadata()
|
||||
export class CustomObjectMetadata extends BaseObjectMetadata {
|
||||
@FieldMetadata({
|
||||
standardId: customObjectStandardFieldIds.name,
|
||||
label: 'Name',
|
||||
description: 'Name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
icon: 'IconAbc',
|
||||
defaultValue: { value: 'Untitled' },
|
||||
})
|
||||
name: string;
|
||||
|
||||
@FieldMetadata({
|
||||
standardId: customObjectStandardFieldIds.position,
|
||||
label: 'Position',
|
||||
description: 'Position',
|
||||
type: FieldMetadataType.POSITION,
|
||||
icon: 'IconHierarchy2',
|
||||
})
|
||||
@IsNullable()
|
||||
@IsSystem()
|
||||
position: number;
|
||||
|
||||
@FieldMetadata({
|
||||
standardId: customObjectStandardFieldIds.activityTargets,
|
||||
type: FieldMetadataType.RELATION,
|
||||
label: 'Activities',
|
||||
description: (objectMetadata) =>
|
||||
`Activities tied to the ${objectMetadata.labelSingular}`,
|
||||
icon: 'IconCheckbox',
|
||||
})
|
||||
@RelationMetadata({
|
||||
type: RelationMetadataType.ONE_TO_MANY,
|
||||
inverseSideTarget: () => ActivityTargetObjectMetadata,
|
||||
onDelete: RelationOnDeleteAction.CASCADE,
|
||||
})
|
||||
@IsNullable()
|
||||
activityTargets: ActivityTargetObjectMetadata[];
|
||||
|
||||
@FieldMetadata({
|
||||
standardId: customObjectStandardFieldIds.favorites,
|
||||
type: FieldMetadataType.RELATION,
|
||||
label: 'Favorites',
|
||||
description: (objectMetadata) =>
|
||||
`Favorites tied to the ${objectMetadata.labelSingular}`,
|
||||
icon: 'IconHeart',
|
||||
})
|
||||
@RelationMetadata({
|
||||
type: RelationMetadataType.ONE_TO_MANY,
|
||||
inverseSideTarget: () => FavoriteObjectMetadata,
|
||||
onDelete: RelationOnDeleteAction.CASCADE,
|
||||
})
|
||||
@IsNullable()
|
||||
@IsSystem()
|
||||
favorites: FavoriteObjectMetadata[];
|
||||
|
||||
@FieldMetadata({
|
||||
standardId: customObjectStandardFieldIds.attachments,
|
||||
type: FieldMetadataType.RELATION,
|
||||
label: 'Attachments',
|
||||
description: (objectMetadata) =>
|
||||
`Attachments tied to the ${objectMetadata.labelSingular}`,
|
||||
icon: 'IconFileImport',
|
||||
})
|
||||
@RelationMetadata({
|
||||
type: RelationMetadataType.ONE_TO_MANY,
|
||||
inverseSideTarget: () => AttachmentObjectMetadata,
|
||||
onDelete: RelationOnDeleteAction.CASCADE,
|
||||
})
|
||||
@IsNullable()
|
||||
attachments: AttachmentObjectMetadata[];
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
import { BaseCustomObjectMetadataDecoratorParams } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-custom-object-metadata.interface';
|
||||
|
||||
import { TypedReflect } from 'src/utils/typed-reflect';
|
||||
|
||||
export function BaseCustomObjectMetadata(
|
||||
params?: BaseCustomObjectMetadataDecoratorParams,
|
||||
): ClassDecorator {
|
||||
return (target) => {
|
||||
const gate = TypedReflect.getMetadata('gate', target);
|
||||
|
||||
TypedReflect.defineMetadata(
|
||||
'extendObjectMetadata',
|
||||
{
|
||||
...params,
|
||||
gate,
|
||||
},
|
||||
target,
|
||||
);
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
import { DynamicRelationFieldMetadataDecoratorParams } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-computed-relation-field-metadata.interface';
|
||||
|
||||
import { TypedReflect } from 'src/utils/typed-reflect';
|
||||
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
|
||||
|
||||
export function DynamicRelationFieldMetadata(
|
||||
params: DynamicRelationFieldMetadataDecoratorParams,
|
||||
): PropertyDecorator {
|
||||
return (target: object, fieldKey: string) => {
|
||||
const isSystem =
|
||||
TypedReflect.getMetadata('isSystem', target, fieldKey) ?? false;
|
||||
const gate = TypedReflect.getMetadata('gate', target, fieldKey);
|
||||
|
||||
TypedReflect.defineMetadata(
|
||||
'dynamicRelationFieldMetadataMap',
|
||||
{
|
||||
type: FieldMetadataType.RELATION,
|
||||
paramsFactory: params,
|
||||
isCustom: false,
|
||||
isNullable: true,
|
||||
isSystem,
|
||||
gate,
|
||||
},
|
||||
target.constructor,
|
||||
);
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
import {
|
||||
FieldMetadataDecoratorParams,
|
||||
ReflectFieldMetadata,
|
||||
} from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-field-metadata.interface';
|
||||
import { GateDecoratorParams } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/gate-decorator.interface';
|
||||
import { FieldMetadataDefaultValue } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-default-value.interface';
|
||||
|
||||
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
|
||||
import { generateTargetColumnMap } from 'src/engine-metadata/field-metadata/utils/generate-target-column-map.util';
|
||||
import { generateDefaultValue } from 'src/engine-metadata/field-metadata/utils/generate-default-value';
|
||||
import { TypedReflect } from 'src/utils/typed-reflect';
|
||||
import { createDeterministicUuid } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/create-deterministic-uuid.util';
|
||||
|
||||
export function FieldMetadata<T extends FieldMetadataType>(
|
||||
params: FieldMetadataDecoratorParams<T>,
|
||||
): PropertyDecorator {
|
||||
return (target: object, fieldKey: string) => {
|
||||
const existingFieldMetadata =
|
||||
TypedReflect.getMetadata('fieldMetadataMap', target.constructor) ?? {};
|
||||
const isNullable =
|
||||
TypedReflect.getMetadata('isNullable', target, fieldKey) ?? false;
|
||||
const isSystem =
|
||||
TypedReflect.getMetadata('isSystem', target, fieldKey) ?? false;
|
||||
const gate = TypedReflect.getMetadata('gate', target, fieldKey);
|
||||
const { joinColumn, standardId, ...restParams } = params;
|
||||
|
||||
TypedReflect.defineMetadata(
|
||||
'fieldMetadataMap',
|
||||
{
|
||||
...existingFieldMetadata,
|
||||
[fieldKey]: generateFieldMetadata<T>(
|
||||
{
|
||||
...restParams,
|
||||
standardId,
|
||||
},
|
||||
fieldKey,
|
||||
isNullable,
|
||||
isSystem,
|
||||
gate,
|
||||
),
|
||||
...(joinColumn && restParams.type === FieldMetadataType.RELATION
|
||||
? {
|
||||
[joinColumn]: generateFieldMetadata<FieldMetadataType.UUID>(
|
||||
{
|
||||
...restParams,
|
||||
standardId: createDeterministicUuid(standardId),
|
||||
type: FieldMetadataType.UUID,
|
||||
label: `${restParams.label} id (foreign key)`,
|
||||
description: `${restParams.description} id foreign key`,
|
||||
defaultValue: null,
|
||||
options: undefined,
|
||||
},
|
||||
joinColumn,
|
||||
isNullable,
|
||||
true,
|
||||
gate,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
target.constructor,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function generateFieldMetadata<T extends FieldMetadataType>(
|
||||
params: FieldMetadataDecoratorParams<T>,
|
||||
fieldKey: string,
|
||||
isNullable: boolean,
|
||||
isSystem: boolean,
|
||||
gate: GateDecoratorParams | undefined = undefined,
|
||||
): ReflectFieldMetadata[string] {
|
||||
const targetColumnMap = generateTargetColumnMap(params.type, false, fieldKey);
|
||||
const defaultValue = (params.defaultValue ??
|
||||
generateDefaultValue(
|
||||
params.type,
|
||||
)) as FieldMetadataDefaultValue<'default'> | null;
|
||||
|
||||
return {
|
||||
name: fieldKey,
|
||||
...params,
|
||||
targetColumnMap,
|
||||
isNullable: params.type === FieldMetadataType.RELATION ? true : isNullable,
|
||||
isSystem,
|
||||
isCustom: false,
|
||||
options: params.options,
|
||||
description: params.description,
|
||||
icon: params.icon,
|
||||
defaultValue,
|
||||
gate,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
import { GateDecoratorParams } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/gate-decorator.interface';
|
||||
|
||||
import { TypedReflect } from 'src/utils/typed-reflect';
|
||||
|
||||
export function Gate(metadata: GateDecoratorParams) {
|
||||
return function (target: object, fieldKey?: string) {
|
||||
if (fieldKey) {
|
||||
TypedReflect.defineMetadata('gate', metadata, target, fieldKey);
|
||||
} else {
|
||||
TypedReflect.defineMetadata('gate', metadata, target);
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
import { TypedReflect } from 'src/utils/typed-reflect';
|
||||
|
||||
export function IsNullable() {
|
||||
return function (target: object, fieldKey: string) {
|
||||
TypedReflect.defineMetadata('isNullable', true, target, fieldKey);
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import { TypedReflect } from 'src/utils/typed-reflect';
|
||||
|
||||
export function IsSystem() {
|
||||
return function (target: object, fieldKey?: string) {
|
||||
if (fieldKey) {
|
||||
TypedReflect.defineMetadata('isSystem', true, target, fieldKey);
|
||||
} else {
|
||||
TypedReflect.defineMetadata('isSystem', true, target);
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
import { ObjectMetadataDecoratorParams } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-object-metadata.interface';
|
||||
|
||||
import { TypedReflect } from 'src/utils/typed-reflect';
|
||||
import { convertClassNameToObjectMetadataName } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/convert-class-to-object-metadata-name.util';
|
||||
|
||||
export function ObjectMetadata(
|
||||
params: ObjectMetadataDecoratorParams,
|
||||
): ClassDecorator {
|
||||
return (target) => {
|
||||
const isSystem = TypedReflect.getMetadata('isSystem', target) ?? false;
|
||||
const gate = TypedReflect.getMetadata('gate', target);
|
||||
const objectName = convertClassNameToObjectMetadataName(target.name);
|
||||
|
||||
TypedReflect.defineMetadata(
|
||||
'objectMetadata',
|
||||
{
|
||||
nameSingular: objectName,
|
||||
...params,
|
||||
targetTableName: 'DEPRECATED',
|
||||
isSystem,
|
||||
isCustom: false,
|
||||
description: params.description,
|
||||
icon: params.icon,
|
||||
gate,
|
||||
},
|
||||
target,
|
||||
);
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import {
|
||||
ReflectRelationMetadata,
|
||||
RelationMetadataDecoratorParams,
|
||||
} from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-relation-metadata.interface';
|
||||
|
||||
import { TypedReflect } from 'src/utils/typed-reflect';
|
||||
import { RelationOnDeleteAction } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
|
||||
|
||||
export function RelationMetadata<TClass extends object>(
|
||||
params: RelationMetadataDecoratorParams<TClass>,
|
||||
): PropertyDecorator {
|
||||
return (target: object, fieldKey: string) => {
|
||||
const relationMetadataCollection =
|
||||
TypedReflect.getMetadata(
|
||||
'reflectRelationMetadataCollection',
|
||||
target.constructor,
|
||||
) ?? [];
|
||||
const gate = TypedReflect.getMetadata('gate', target, fieldKey);
|
||||
|
||||
TypedReflect.defineMetadata(
|
||||
'reflectRelationMetadataCollection',
|
||||
[
|
||||
...relationMetadataCollection,
|
||||
{
|
||||
target,
|
||||
fieldKey,
|
||||
...params,
|
||||
onDelete: params.onDelete ?? RelationOnDeleteAction.SET_NULL,
|
||||
gate,
|
||||
} satisfies ReflectRelationMetadata,
|
||||
],
|
||||
target.constructor,
|
||||
);
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface';
|
||||
import { FeatureFlagMap } from 'src/engine/modules/feature-flag/interfaces/feature-flag-map.interface';
|
||||
|
||||
import { FeatureFlagEntity } from 'src/engine/modules/feature-flag/feature-flag.entity';
|
||||
|
||||
@Injectable()
|
||||
export class FeatureFlagFactory {
|
||||
constructor(
|
||||
@InjectRepository(FeatureFlagEntity, 'core')
|
||||
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
|
||||
) {}
|
||||
|
||||
async create(context: WorkspaceSyncContext): Promise<FeatureFlagMap> {
|
||||
const workspaceFeatureFlags = await this.featureFlagRepository.find({
|
||||
where: { workspaceId: context.workspaceId },
|
||||
});
|
||||
|
||||
const workspaceFeatureFlagsMap = workspaceFeatureFlags.reduce(
|
||||
(result, currentFeatureFlag) => {
|
||||
result[currentFeatureFlag.key] = currentFeatureFlag.value;
|
||||
|
||||
return result;
|
||||
},
|
||||
{} as FeatureFlagMap,
|
||||
);
|
||||
|
||||
return workspaceFeatureFlagsMap;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import { FeatureFlagFactory } from './feature-flags.factory';
|
||||
import { StandardFieldFactory } from './standard-field.factory';
|
||||
import { StandardObjectFactory } from './standard-object.factory';
|
||||
import { StandardRelationFactory } from './standard-relation.factory';
|
||||
|
||||
export const workspaceSyncMetadataFactories = [
|
||||
FeatureFlagFactory,
|
||||
StandardFieldFactory,
|
||||
StandardObjectFactory,
|
||||
StandardRelationFactory,
|
||||
];
|
||||
@ -0,0 +1,102 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface';
|
||||
import { FeatureFlagMap } from 'src/engine/modules/feature-flag/interfaces/feature-flag-map.interface';
|
||||
import {
|
||||
PartialComputedFieldMetadata,
|
||||
PartialFieldMetadata,
|
||||
} from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-field-metadata.interface';
|
||||
import { ReflectFieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-field-metadata.interface';
|
||||
import { ReflectObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-object-metadata.interface';
|
||||
import { ReflectDynamicRelationFieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-computed-relation-field-metadata.interface';
|
||||
|
||||
import { TypedReflect } from 'src/utils/typed-reflect';
|
||||
import { isGatedAndNotEnabled } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-gate-and-not-enabled.util';
|
||||
|
||||
@Injectable()
|
||||
export class StandardFieldFactory {
|
||||
create(
|
||||
target: object,
|
||||
context: WorkspaceSyncContext,
|
||||
workspaceFeatureFlagsMap: FeatureFlagMap,
|
||||
): (PartialFieldMetadata | PartialComputedFieldMetadata)[] {
|
||||
const reflectObjectMetadata = TypedReflect.getMetadata(
|
||||
'objectMetadata',
|
||||
target,
|
||||
);
|
||||
const reflectFieldMetadataMap =
|
||||
TypedReflect.getMetadata('fieldMetadataMap', target) ?? [];
|
||||
const reflectDynamicRelationFieldMetadataMap = TypedReflect.getMetadata(
|
||||
'dynamicRelationFieldMetadataMap',
|
||||
target,
|
||||
);
|
||||
const partialFieldMetadataCollection: (
|
||||
| PartialFieldMetadata
|
||||
| PartialComputedFieldMetadata
|
||||
)[] = Object.values(reflectFieldMetadataMap)
|
||||
.map((reflectFieldMetadata) =>
|
||||
this.createFieldMetadata(
|
||||
reflectObjectMetadata,
|
||||
reflectFieldMetadata,
|
||||
context,
|
||||
workspaceFeatureFlagsMap,
|
||||
),
|
||||
)
|
||||
.filter((metadata): metadata is PartialFieldMetadata => !!metadata);
|
||||
const partialComputedFieldMetadata = this.createComputedFieldMetadata(
|
||||
reflectDynamicRelationFieldMetadataMap,
|
||||
context,
|
||||
workspaceFeatureFlagsMap,
|
||||
);
|
||||
|
||||
if (partialComputedFieldMetadata) {
|
||||
partialFieldMetadataCollection.push(partialComputedFieldMetadata);
|
||||
}
|
||||
|
||||
return partialFieldMetadataCollection;
|
||||
}
|
||||
|
||||
private createFieldMetadata(
|
||||
reflectObjectMetadata: ReflectObjectMetadata | undefined,
|
||||
reflectFieldMetadata: ReflectFieldMetadata[string],
|
||||
context: WorkspaceSyncContext,
|
||||
workspaceFeatureFlagsMap: FeatureFlagMap,
|
||||
): PartialFieldMetadata | undefined {
|
||||
if (
|
||||
isGatedAndNotEnabled(reflectFieldMetadata.gate, workspaceFeatureFlagsMap)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...reflectFieldMetadata,
|
||||
workspaceId: context.workspaceId,
|
||||
isSystem:
|
||||
reflectObjectMetadata?.isSystem || reflectFieldMetadata.isSystem,
|
||||
};
|
||||
}
|
||||
|
||||
private createComputedFieldMetadata(
|
||||
reflectDynamicRelationFieldMetadata:
|
||||
| ReflectDynamicRelationFieldMetadata
|
||||
| undefined,
|
||||
context: WorkspaceSyncContext,
|
||||
workspaceFeatureFlagsMap: FeatureFlagMap,
|
||||
): PartialComputedFieldMetadata | undefined {
|
||||
if (
|
||||
!reflectDynamicRelationFieldMetadata ||
|
||||
isGatedAndNotEnabled(
|
||||
reflectDynamicRelationFieldMetadata.gate,
|
||||
workspaceFeatureFlagsMap,
|
||||
)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...reflectDynamicRelationFieldMetadata,
|
||||
workspaceId: context.workspaceId,
|
||||
isSystem: reflectDynamicRelationFieldMetadata.isSystem,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface';
|
||||
import { PartialObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-object-metadata.interface';
|
||||
import { FeatureFlagMap } from 'src/engine/modules/feature-flag/interfaces/feature-flag-map.interface';
|
||||
|
||||
import { BaseObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects/base.object-metadata';
|
||||
import { TypedReflect } from 'src/utils/typed-reflect';
|
||||
import { isGatedAndNotEnabled } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-gate-and-not-enabled.util';
|
||||
|
||||
import { StandardFieldFactory } from './standard-field.factory';
|
||||
|
||||
@Injectable()
|
||||
export class StandardObjectFactory {
|
||||
constructor(private readonly standardFieldFactory: StandardFieldFactory) {}
|
||||
|
||||
create(
|
||||
standardObjectMetadataDefinitions: (typeof BaseObjectMetadata)[],
|
||||
context: WorkspaceSyncContext,
|
||||
workspaceFeatureFlagsMap: FeatureFlagMap,
|
||||
): PartialObjectMetadata[] {
|
||||
return standardObjectMetadataDefinitions
|
||||
.map((metadata) =>
|
||||
this.createObjectMetadata(metadata, context, workspaceFeatureFlagsMap),
|
||||
)
|
||||
.filter((metadata): metadata is PartialObjectMetadata => !!metadata);
|
||||
}
|
||||
|
||||
private createObjectMetadata(
|
||||
metadata: typeof BaseObjectMetadata,
|
||||
context: WorkspaceSyncContext,
|
||||
workspaceFeatureFlagsMap: FeatureFlagMap,
|
||||
): PartialObjectMetadata | undefined {
|
||||
const objectMetadata = TypedReflect.getMetadata('objectMetadata', metadata);
|
||||
|
||||
if (!objectMetadata) {
|
||||
throw new Error(
|
||||
`Object metadata decorator not found, can\'t parse ${metadata.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (isGatedAndNotEnabled(objectMetadata.gate, workspaceFeatureFlagsMap)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const fields = this.standardFieldFactory.create(
|
||||
metadata,
|
||||
context,
|
||||
workspaceFeatureFlagsMap,
|
||||
);
|
||||
|
||||
return {
|
||||
...objectMetadata,
|
||||
workspaceId: context.workspaceId,
|
||||
dataSourceId: context.dataSourceId,
|
||||
fields,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,169 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface';
|
||||
import { FeatureFlagMap } from 'src/engine/modules/feature-flag/interfaces/feature-flag-map.interface';
|
||||
|
||||
import { BaseObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-objects/base.object-metadata';
|
||||
import { TypedReflect } from 'src/utils/typed-reflect';
|
||||
import { isGatedAndNotEnabled } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-gate-and-not-enabled.util';
|
||||
import { assert } from 'src/utils/assert';
|
||||
import { RelationMetadataEntity } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { convertClassNameToObjectMetadataName } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/convert-class-to-object-metadata-name.util';
|
||||
|
||||
interface CustomRelationFactory {
|
||||
object: ObjectMetadataEntity;
|
||||
metadata: typeof BaseObjectMetadata;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StandardRelationFactory {
|
||||
create(
|
||||
customObjectFactories: CustomRelationFactory[],
|
||||
context: WorkspaceSyncContext,
|
||||
originalObjectMetadataMap: Record<string, ObjectMetadataEntity>,
|
||||
workspaceFeatureFlagsMap: FeatureFlagMap,
|
||||
): Partial<RelationMetadataEntity>[];
|
||||
|
||||
create(
|
||||
standardObjectMetadataDefinitions: (typeof BaseObjectMetadata)[],
|
||||
context: WorkspaceSyncContext,
|
||||
originalObjectMetadataMap: Record<string, ObjectMetadataEntity>,
|
||||
workspaceFeatureFlagsMap: FeatureFlagMap,
|
||||
): Partial<RelationMetadataEntity>[];
|
||||
|
||||
create(
|
||||
standardObjectMetadataDefinitionsOrCustomObjectFactories:
|
||||
| (typeof BaseObjectMetadata)[]
|
||||
| {
|
||||
object: ObjectMetadataEntity;
|
||||
metadata: typeof BaseObjectMetadata;
|
||||
}[],
|
||||
context: WorkspaceSyncContext,
|
||||
originalObjectMetadataMap: Record<string, ObjectMetadataEntity>,
|
||||
workspaceFeatureFlagsMap: FeatureFlagMap,
|
||||
): Partial<RelationMetadataEntity>[] {
|
||||
return standardObjectMetadataDefinitionsOrCustomObjectFactories.flatMap(
|
||||
(
|
||||
standardObjectMetadata:
|
||||
| typeof BaseObjectMetadata
|
||||
| CustomRelationFactory,
|
||||
) =>
|
||||
this.createRelationMetadata(
|
||||
standardObjectMetadata,
|
||||
context,
|
||||
originalObjectMetadataMap,
|
||||
workspaceFeatureFlagsMap,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private createRelationMetadata(
|
||||
standardObjectMetadataOrCustomRelationFactory:
|
||||
| typeof BaseObjectMetadata
|
||||
| CustomRelationFactory,
|
||||
context: WorkspaceSyncContext,
|
||||
originalObjectMetadataMap: Record<string, ObjectMetadataEntity>,
|
||||
workspaceFeatureFlagsMap: FeatureFlagMap,
|
||||
): Partial<RelationMetadataEntity>[] {
|
||||
const standardObjectMetadata =
|
||||
'metadata' in standardObjectMetadataOrCustomRelationFactory
|
||||
? standardObjectMetadataOrCustomRelationFactory.metadata
|
||||
: standardObjectMetadataOrCustomRelationFactory;
|
||||
const objectMetadata = TypedReflect.getMetadata(
|
||||
'metadata' in standardObjectMetadataOrCustomRelationFactory
|
||||
? 'extendObjectMetadata'
|
||||
: 'objectMetadata',
|
||||
standardObjectMetadata,
|
||||
);
|
||||
const reflectRelationMetadataCollection = TypedReflect.getMetadata(
|
||||
'reflectRelationMetadataCollection',
|
||||
standardObjectMetadata,
|
||||
);
|
||||
|
||||
if (!objectMetadata) {
|
||||
throw new Error(
|
||||
`Object metadata decorator not found, can\'t parse ${standardObjectMetadata.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!reflectRelationMetadataCollection ||
|
||||
isGatedAndNotEnabled(objectMetadata?.gate, workspaceFeatureFlagsMap)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return reflectRelationMetadataCollection
|
||||
.filter(
|
||||
(reflectRelationMetadata) =>
|
||||
!isGatedAndNotEnabled(
|
||||
reflectRelationMetadata.gate,
|
||||
workspaceFeatureFlagsMap,
|
||||
),
|
||||
)
|
||||
.map((reflectRelationMetadata) => {
|
||||
// Compute reflect relation metadata
|
||||
const fromObjectNameSingular =
|
||||
'object' in standardObjectMetadataOrCustomRelationFactory
|
||||
? standardObjectMetadataOrCustomRelationFactory.object.nameSingular
|
||||
: convertClassNameToObjectMetadataName(
|
||||
reflectRelationMetadata.target.constructor.name,
|
||||
);
|
||||
const toObjectNameSingular = convertClassNameToObjectMetadataName(
|
||||
reflectRelationMetadata.inverseSideTarget().name,
|
||||
);
|
||||
const fromFieldMetadataName = reflectRelationMetadata.fieldKey;
|
||||
const toFieldMetadataName =
|
||||
(reflectRelationMetadata.inverseSideFieldKey as string | undefined) ??
|
||||
fromObjectNameSingular;
|
||||
const fromObjectMetadata =
|
||||
originalObjectMetadataMap[fromObjectNameSingular];
|
||||
|
||||
assert(
|
||||
fromObjectMetadata,
|
||||
`Object ${fromObjectNameSingular} not found in DB
|
||||
for relation FROM defined in class ${fromObjectNameSingular}`,
|
||||
);
|
||||
|
||||
const toObjectMetadata =
|
||||
originalObjectMetadataMap[toObjectNameSingular];
|
||||
|
||||
assert(
|
||||
toObjectMetadata,
|
||||
`Object ${toObjectNameSingular} not found in DB
|
||||
for relation TO defined in class ${fromObjectNameSingular}`,
|
||||
);
|
||||
|
||||
const fromFieldMetadata = fromObjectMetadata?.fields.find(
|
||||
(field) => field.name === fromFieldMetadataName,
|
||||
);
|
||||
|
||||
assert(
|
||||
fromFieldMetadata,
|
||||
`Field ${fromFieldMetadataName} not found in object ${fromObjectNameSingular}
|
||||
for relation FROM defined in class ${fromObjectNameSingular}`,
|
||||
);
|
||||
|
||||
const toFieldMetadata = toObjectMetadata?.fields.find(
|
||||
(field) => field.name === toFieldMetadataName,
|
||||
);
|
||||
|
||||
assert(
|
||||
toFieldMetadata,
|
||||
`Field ${toFieldMetadataName} not found in object ${toObjectNameSingular}
|
||||
for relation TO defined in class ${fromObjectNameSingular}`,
|
||||
);
|
||||
|
||||
return {
|
||||
relationType: reflectRelationMetadata.type,
|
||||
fromObjectMetadataId: fromObjectMetadata?.id,
|
||||
toObjectMetadataId: toObjectMetadata?.id,
|
||||
fromFieldMetadataId: fromFieldMetadata?.id,
|
||||
toFieldMetadataId: toFieldMetadata?.id,
|
||||
workspaceId: context.workspaceId,
|
||||
onDeleteAction: reflectRelationMetadata.onDelete,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
import { FieldMetadataEntity } from 'src/engine-metadata/field-metadata/field-metadata.entity';
|
||||
import { RelationMetadataEntity } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
|
||||
|
||||
import { ComputedPartialFieldMetadata } from './partial-field-metadata.interface';
|
||||
import { ComputedPartialObjectMetadata } from './partial-object-metadata.interface';
|
||||
|
||||
export const enum ComparatorAction {
|
||||
SKIP = 'SKIP',
|
||||
CREATE = 'CREATE',
|
||||
UPDATE = 'UPDATE',
|
||||
DELETE = 'DELETE',
|
||||
}
|
||||
|
||||
export interface ComparatorSkipResult {
|
||||
action: ComparatorAction.SKIP;
|
||||
}
|
||||
|
||||
export interface ComparatorCreateResult<T> {
|
||||
action: ComparatorAction.CREATE;
|
||||
object: T;
|
||||
}
|
||||
|
||||
export interface ComparatorUpdateResult<T> {
|
||||
action: ComparatorAction.UPDATE;
|
||||
object: T;
|
||||
}
|
||||
|
||||
export interface ComparatorDeleteResult<T> {
|
||||
action: ComparatorAction.DELETE;
|
||||
object: T;
|
||||
}
|
||||
|
||||
export type ObjectComparatorResult =
|
||||
| ComparatorSkipResult
|
||||
| ComparatorCreateResult<ComputedPartialObjectMetadata>
|
||||
| ComparatorUpdateResult<Partial<ComputedPartialObjectMetadata>>;
|
||||
|
||||
export type FieldComparatorResult =
|
||||
| ComparatorSkipResult
|
||||
| ComparatorCreateResult<ComputedPartialFieldMetadata>
|
||||
| ComparatorUpdateResult<
|
||||
Partial<ComputedPartialFieldMetadata> & { id: string }
|
||||
>
|
||||
| ComparatorDeleteResult<FieldMetadataEntity>;
|
||||
|
||||
export type RelationComparatorResult =
|
||||
| ComparatorCreateResult<Partial<RelationMetadataEntity>>
|
||||
| ComparatorDeleteResult<RelationMetadataEntity>
|
||||
| ComparatorUpdateResult<Partial<RelationMetadataEntity>>;
|
||||
@ -0,0 +1,3 @@
|
||||
export interface GateDecoratorParams {
|
||||
featureFlag: string;
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
import { PartialFieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-field-metadata.interface';
|
||||
import { PartialObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-object-metadata.interface';
|
||||
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
import { FieldMetadataEntity } from 'src/engine-metadata/field-metadata/field-metadata.entity';
|
||||
|
||||
export type MappedFieldMetadata = Record<string, PartialFieldMetadata>;
|
||||
|
||||
export interface MappedObjectMetadata
|
||||
extends Omit<PartialObjectMetadata, 'fields'> {
|
||||
fields: MappedFieldMetadata;
|
||||
}
|
||||
|
||||
export type MappedFieldMetadataEntity = Record<string, FieldMetadataEntity>;
|
||||
|
||||
export interface MappedObjectMetadataEntity
|
||||
extends Omit<ObjectMetadataEntity, 'fields'> {
|
||||
fields: MappedFieldMetadataEntity;
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
import { ReflectDynamicRelationFieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-computed-relation-field-metadata.interface';
|
||||
import { ReflectFieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-field-metadata.interface';
|
||||
|
||||
export type PartialFieldMetadata = Omit<
|
||||
ReflectFieldMetadata[string],
|
||||
'joinColumn'
|
||||
> & {
|
||||
workspaceId: string;
|
||||
objectMetadataId?: string;
|
||||
};
|
||||
|
||||
export type PartialComputedFieldMetadata =
|
||||
ReflectDynamicRelationFieldMetadata & {
|
||||
workspaceId: string;
|
||||
objectMetadataId?: string;
|
||||
};
|
||||
|
||||
export type ComputedPartialFieldMetadata = {
|
||||
[K in keyof PartialFieldMetadata]: ExcludeFunctions<PartialFieldMetadata[K]>;
|
||||
};
|
||||
@ -0,0 +1,21 @@
|
||||
import {
|
||||
ComputedPartialFieldMetadata,
|
||||
PartialComputedFieldMetadata,
|
||||
PartialFieldMetadata,
|
||||
} from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-field-metadata.interface';
|
||||
import { ReflectObjectMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-object-metadata.interface';
|
||||
|
||||
export type PartialObjectMetadata = ReflectObjectMetadata & {
|
||||
id?: string;
|
||||
workspaceId: string;
|
||||
dataSourceId: string;
|
||||
fields: (PartialFieldMetadata | PartialComputedFieldMetadata)[];
|
||||
};
|
||||
|
||||
export type ComputedPartialObjectMetadata = Omit<
|
||||
PartialObjectMetadata,
|
||||
'standardId' | 'fields'
|
||||
> & {
|
||||
standardId: string | null;
|
||||
fields: ComputedPartialFieldMetadata[];
|
||||
};
|
||||
@ -0,0 +1,10 @@
|
||||
import { ReflectRelationMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/reflect-relation-metadata.interface';
|
||||
|
||||
export type PartialRelationMetadata = ReflectRelationMetadata & {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
fromObjectMetadataId: string;
|
||||
toObjectMetadataId: string;
|
||||
fromFieldMetadataId: string;
|
||||
toFieldMetadataId: string;
|
||||
};
|
||||
@ -0,0 +1,24 @@
|
||||
import { GateDecoratorParams } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/gate-decorator.interface';
|
||||
|
||||
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
|
||||
export type DynamicRelationFieldMetadataDecoratorParams = (
|
||||
oppositeObjectMetadata: ObjectMetadataEntity,
|
||||
) => {
|
||||
standardId: string;
|
||||
name: string;
|
||||
label: string;
|
||||
joinColumn: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
export interface ReflectDynamicRelationFieldMetadata {
|
||||
type: FieldMetadataType.RELATION;
|
||||
paramsFactory: DynamicRelationFieldMetadataDecoratorParams;
|
||||
isNullable: boolean;
|
||||
isSystem: boolean;
|
||||
isCustom: boolean;
|
||||
gate?: GateDecoratorParams;
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import { GateDecoratorParams } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/gate-decorator.interface';
|
||||
|
||||
export type BaseCustomObjectMetadataDecoratorParams =
|
||||
| { allowObjectNameList?: string[] }
|
||||
| { denyObjectNameList?: string[] };
|
||||
|
||||
export type ReflectBaseCustomObjectMetadata =
|
||||
BaseCustomObjectMetadataDecoratorParams & {
|
||||
gate?: GateDecoratorParams;
|
||||
};
|
||||
@ -0,0 +1,37 @@
|
||||
import { FieldMetadataDefaultValue } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-default-value.interface';
|
||||
import { GateDecoratorParams } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/gate-decorator.interface';
|
||||
import { FieldMetadataOptions } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-options.interface';
|
||||
import { FieldMetadataTargetColumnMap } from 'src/engine-metadata/field-metadata/interfaces/field-metadata-target-column-map.interface';
|
||||
|
||||
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine-metadata/object-metadata/object-metadata.entity';
|
||||
|
||||
export interface FieldMetadataDecoratorParams<
|
||||
T extends FieldMetadataType | 'default',
|
||||
> {
|
||||
standardId: string;
|
||||
type: T;
|
||||
label: string | ((objectMetadata: ObjectMetadataEntity) => string);
|
||||
description?: string | ((objectMetadata: ObjectMetadataEntity) => string);
|
||||
icon?: string;
|
||||
defaultValue?: FieldMetadataDefaultValue<T>;
|
||||
joinColumn?: string;
|
||||
options?: FieldMetadataOptions<T>;
|
||||
}
|
||||
|
||||
export interface ReflectFieldMetadata {
|
||||
[key: string]: Omit<
|
||||
FieldMetadataDecoratorParams<'default'>,
|
||||
'defaultValue' | 'type' | 'options'
|
||||
> & {
|
||||
name: string;
|
||||
type: FieldMetadataType;
|
||||
targetColumnMap: FieldMetadataTargetColumnMap<'default'>;
|
||||
isNullable: boolean;
|
||||
isSystem: boolean;
|
||||
isCustom: boolean;
|
||||
defaultValue: FieldMetadataDefaultValue<'default'> | null;
|
||||
gate?: GateDecoratorParams;
|
||||
options?: FieldMetadataOptions<'default'> | null;
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user