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:
Jérémy M
2024-03-15 14:40:58 +01:00
committed by GitHub
parent 52f1b3ac98
commit 94487f6737
760 changed files with 3215 additions and 3155 deletions

View File

@ -0,0 +1,13 @@
import { Settings } from './interfaces/settings.interface';
export const settings: Settings = {
storage: {
imageCropSizes: {
'profile-picture': ['original'],
'workspace-logo': ['original'],
'person-picture': ['original'],
},
maxFileSize: '10MB',
},
minLengthOfStringForDuplicateCheck: 3,
};

View File

@ -0,0 +1,15 @@
import { FileFolder } from 'src/engine/modules/file/interfaces/file-folder.interface';
import { ShortCropSize } from 'src/utils/image';
type ValueOfFileFolder = `${FileFolder}`;
export interface Settings {
storage: {
imageCropSizes: {
[key in ValueOfFileFolder]?: ShortCropSize[];
};
maxFileSize: `${number}MB`;
};
minLengthOfStringForDuplicateCheck: number;
}

View File

@ -0,0 +1,11 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { getRequest } from 'src/utils/extract-request';
export const UserAbility = createParamDecorator(
(_: unknown, context: ExecutionContext) => {
const request = getRequest(context);
return request.ability;
},
);

View File

@ -0,0 +1,23 @@
import {
ExecutionContext,
ForbiddenException,
createParamDecorator,
} from '@nestjs/common';
import { getRequest } from 'src/utils/extract-request';
interface DecoratorOptions {
allowUndefined?: boolean;
}
export const AuthUser = createParamDecorator(
(options: DecoratorOptions | undefined, ctx: ExecutionContext) => {
const request = getRequest(ctx);
if (!options?.allowUndefined && (!request.user || !request.user.user)) {
throw new ForbiddenException("You're not authorized to do this");
}
return request.user ? request.user.user : undefined;
},
);

View File

@ -0,0 +1,11 @@
import { ExecutionContext, createParamDecorator } from '@nestjs/common';
import { getRequest } from 'src/utils/extract-request';
export const AuthWorkspace = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = getRequest(ctx);
return request.user ? request.user.workspace : undefined;
},
);

View File

@ -0,0 +1,94 @@
import { HttpException } from '@nestjs/common';
import { ExceptionHandlerUser } from 'src/integrations/exception-handler/interfaces/exception-handler-user.interface';
import {
AuthenticationError,
BaseGraphQLError,
ForbiddenError,
ValidationError,
NotFoundError,
ConflictError,
} from 'src/engine/filters/utils/graphql-errors.util';
import { ExceptionHandlerService } from 'src/integrations/exception-handler/exception-handler.service';
const graphQLPredefinedExceptions = {
400: ValidationError,
401: AuthenticationError,
403: ForbiddenError,
404: NotFoundError,
409: ConflictError,
};
export const handleExceptionAndConvertToGraphQLError = (
exception: Error,
exceptionHandlerService: ExceptionHandlerService,
user?: ExceptionHandlerUser,
): BaseGraphQLError => {
handleException(exception, exceptionHandlerService, user);
return convertExceptionToGraphQLError(exception);
};
export const filterException = (exception: Error): boolean => {
if (exception instanceof HttpException && exception.getStatus() < 500) {
return true;
}
return false;
};
export const handleException = (
exception: Error,
exceptionHandlerService: ExceptionHandlerService,
user?: ExceptionHandlerUser,
): void => {
if (filterException(exception)) {
return;
}
exceptionHandlerService.captureExceptions([exception], { user });
};
export const convertExceptionToGraphQLError = (
exception: Error,
): BaseGraphQLError => {
if (exception instanceof HttpException) {
return convertHttpExceptionToGraphql(exception);
}
return convertExceptionToGraphql(exception);
};
export const convertHttpExceptionToGraphql = (exception: HttpException) => {
const status = exception.getStatus();
let error: BaseGraphQLError;
if (status in graphQLPredefinedExceptions) {
const message = exception.getResponse()['message'] ?? exception.message;
error = new graphQLPredefinedExceptions[exception.getStatus()](message);
} else {
error = new BaseGraphQLError(
'Internal Server Error',
exception.getStatus().toString(),
);
}
// Only show the stack trace in development mode
if (process.env.NODE_ENV === 'development') {
error.stack = exception.stack;
error.extensions['response'] = exception.getResponse();
}
return error;
};
export const convertExceptionToGraphql = (exception: Error) => {
const error = new BaseGraphQLError(exception.name, 'INTERNAL_SERVER_ERROR');
error.stack = exception.stack;
error.extensions['response'] = exception.message;
return error;
};

View File

@ -0,0 +1,151 @@
import {
ASTNode,
GraphQLError,
GraphQLFormattedError,
Source,
SourceLocation,
} from 'graphql';
declare module 'graphql' {
export interface GraphQLErrorExtensions {
exception?: {
code?: string;
stacktrace?: ReadonlyArray<string>;
};
}
}
export class BaseGraphQLError extends Error implements GraphQLError {
public extensions: Record<string, any>;
override readonly name!: string;
readonly locations: ReadonlyArray<SourceLocation> | undefined;
readonly path: ReadonlyArray<string | number> | undefined;
readonly source: Source | undefined;
readonly positions: ReadonlyArray<number> | undefined;
readonly nodes: ReadonlyArray<ASTNode> | undefined;
public originalError: Error | undefined;
[key: string]: any;
constructor(
message: string,
code?: string,
extensions?: Record<string, any>,
) {
super(message);
// if no name provided, use the default. defineProperty ensures that it stays non-enumerable
if (!this.name) {
Object.defineProperty(this, 'name', { value: 'ApolloError' });
}
if (extensions?.extensions) {
throw Error(
'Pass extensions directly as the third argument of the ApolloError constructor: `new ' +
'ApolloError(message, code, {myExt: value})`, not `new ApolloError(message, code, ' +
'{extensions: {myExt: value}})`',
);
}
this.extensions = { ...extensions, code };
}
toJSON(): GraphQLFormattedError {
return toGraphQLError(this).toJSON();
}
override toString(): string {
return toGraphQLError(this).toString();
}
get [Symbol.toStringTag](): string {
return this.name;
}
}
function toGraphQLError(error: BaseGraphQLError): GraphQLError {
return new GraphQLError(error.message, {
nodes: error.nodes,
source: error.source,
positions: error.positions,
path: error.path,
originalError: error.originalError,
extensions: error.extensions,
});
}
export class SyntaxError extends BaseGraphQLError {
constructor(message: string) {
super(message, 'GRAPHQL_PARSE_FAILED');
Object.defineProperty(this, 'name', { value: 'SyntaxError' });
}
}
export class ValidationError extends BaseGraphQLError {
constructor(message: string) {
super(message, 'GRAPHQL_VALIDATION_FAILED');
Object.defineProperty(this, 'name', { value: 'ValidationError' });
}
}
export class AuthenticationError extends BaseGraphQLError {
constructor(message: string, extensions?: Record<string, any>) {
super(message, 'UNAUTHENTICATED', extensions);
Object.defineProperty(this, 'name', { value: 'AuthenticationError' });
}
}
export class ForbiddenError extends BaseGraphQLError {
constructor(message: string, extensions?: Record<string, any>) {
super(message, 'FORBIDDEN', extensions);
Object.defineProperty(this, 'name', { value: 'ForbiddenError' });
}
}
export class PersistedQueryNotFoundError extends BaseGraphQLError {
constructor() {
super('PersistedQueryNotFound', 'PERSISTED_QUERY_NOT_FOUND');
Object.defineProperty(this, 'name', {
value: 'PersistedQueryNotFoundError',
});
}
}
export class PersistedQueryNotSupportedError extends BaseGraphQLError {
constructor() {
super('PersistedQueryNotSupported', 'PERSISTED_QUERY_NOT_SUPPORTED');
Object.defineProperty(this, 'name', {
value: 'PersistedQueryNotSupportedError',
});
}
}
export class UserInputError extends BaseGraphQLError {
constructor(message: string, extensions?: Record<string, any>) {
super(message, 'BAD_USER_INPUT', extensions);
Object.defineProperty(this, 'name', { value: 'UserInputError' });
}
}
export class NotFoundError extends BaseGraphQLError {
constructor(message: string, extensions?: Record<string, any>) {
super(message, 'NOT_FOUND', extensions);
Object.defineProperty(this, 'name', { value: 'NotFoundError' });
}
}
export class ConflictError extends BaseGraphQLError {
constructor(message: string, extensions?: Record<string, any>) {
super(message, 'CONFLICT', extensions);
Object.defineProperty(this, 'name', { value: 'ConflictError' });
}
}

View File

@ -0,0 +1,122 @@
import { TestingModule, Test } from '@nestjs/testing';
import { ArgsAliasFactory } from 'src/engine/graphql/workspace-query-builder/factories/args-alias.factory';
import { ArgsStringFactory } from 'src/engine/graphql/workspace-query-builder/factories/args-string.factory';
describe('ArgsStringFactory', () => {
let service: ArgsStringFactory;
const argsAliasCreate = jest.fn();
beforeEach(async () => {
jest.resetAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
ArgsStringFactory,
{
provide: ArgsAliasFactory,
useValue: {
create: argsAliasCreate,
},
},
],
}).compile();
service = module.get<ArgsStringFactory>(ArgsStringFactory);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('create', () => {
it('should return null when args are missing', () => {
const args = undefined;
const result = service.create(args, []);
expect(result).toBeNull();
});
it('should return a string with the args when args are present', () => {
const args = {
id: '1',
name: 'field_name',
};
argsAliasCreate.mockReturnValue(args);
const result = service.create(args, []);
expect(result).toEqual('id: "1", name: "field_name"');
});
it('should return a string with the args when args are present and the value is an object', () => {
const args = {
id: '1',
name: {
firstName: 'test',
},
};
argsAliasCreate.mockReturnValue(args);
const result = service.create(args, []);
expect(result).toEqual('id: "1", name: {firstName:"test"}');
});
it('when orderBy is present, should return an array of objects', () => {
const args = {
orderBy: {
id: 'AscNullsFirst',
name: 'AscNullsFirst',
},
};
argsAliasCreate.mockReturnValue(args);
const result = service.create(args, []);
expect(result).toEqual(
'orderBy: [{id: AscNullsFirst}, {name: AscNullsFirst}]',
);
});
it('when orderBy is present with position criteria, should return position at the end of the list', () => {
const args = {
orderBy: {
position: 'AscNullsFirst',
id: 'AscNullsFirst',
name: 'AscNullsFirst',
},
};
argsAliasCreate.mockReturnValue(args);
const result = service.create(args, []);
expect(result).toEqual(
'orderBy: [{id: AscNullsFirst}, {name: AscNullsFirst}, {position: AscNullsFirst}]',
);
});
it('when orderBy is present with position in the middle, should return position at the end of the list', () => {
const args = {
orderBy: {
id: 'AscNullsFirst',
position: 'AscNullsFirst',
name: 'AscNullsFirst',
},
};
argsAliasCreate.mockReturnValue(args);
const result = service.create(args, []);
expect(result).toEqual(
'orderBy: [{id: AscNullsFirst}, {name: AscNullsFirst}, {position: AscNullsFirst}]',
);
});
});
});

View File

@ -0,0 +1,203 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RecordFilter } from 'src/engine/graphql/workspace-query-builder/interfaces/record.interface';
import { FindDuplicatesResolverArgs } from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { ArgsAliasFactory } from 'src/engine/graphql/workspace-query-builder/factories/args-alias.factory';
import { FieldsStringFactory } from 'src/engine/graphql/workspace-query-builder/factories/fields-string.factory';
import { FindDuplicatesQueryFactory } from 'src/engine/graphql/workspace-query-builder/factories/find-duplicates-query.factory';
import { workspaceQueryBuilderOptions } from 'src/engine/graphql/workspace-query-builder/utils-test/workspace-query-builder-options';
describe('FindDuplicatesQueryFactory', () => {
let service: FindDuplicatesQueryFactory;
const argAliasCreate = jest.fn();
beforeEach(async () => {
jest.resetAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
FindDuplicatesQueryFactory,
{
provide: FieldsStringFactory,
useValue: {
create: jest.fn().mockResolvedValue('fieldsString'),
// Mock implementation of FieldsStringFactory methods if needed
},
},
{
provide: ArgsAliasFactory,
useValue: {
create: argAliasCreate,
// Mock implementation of ArgsAliasFactory methods if needed
},
},
],
}).compile();
service = module.get<FindDuplicatesQueryFactory>(
FindDuplicatesQueryFactory,
);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('create', () => {
it('should return (first: 0) as a filter when args are missing', async () => {
const args: FindDuplicatesResolverArgs<RecordFilter> = {};
const query = await service.create(args, workspaceQueryBuilderOptions);
expect(query.trim()).toEqual(`query {
objectNameCollection(first: 0) {
fieldsString
}
}`);
});
it('should use firstName and lastName as a filter when both args are present', async () => {
argAliasCreate.mockReturnValue({
nameFirstName: 'John',
nameLastName: 'Doe',
});
const args: FindDuplicatesResolverArgs<RecordFilter> = {
data: {
name: {
firstName: 'John',
lastName: 'Doe',
},
} as unknown as RecordFilter,
};
const query = await service.create(args, {
...workspaceQueryBuilderOptions,
objectMetadataItem: {
...workspaceQueryBuilderOptions.objectMetadataItem,
nameSingular: 'person',
},
});
expect(query.trim()).toEqual(`query {
personCollection(filter: {or:[{nameFirstName:{eq:\"John\"},nameLastName:{eq:\"Doe\"}}]}) {
fieldsString
}
}`);
});
it('should ignore an argument if the string length is less than 3', async () => {
argAliasCreate.mockReturnValue({
linkedinLinkUrl: 'ab',
email: 'test@test.com',
});
const args: FindDuplicatesResolverArgs<RecordFilter> = {
data: {
linkedinLinkUrl: 'ab',
email: 'test@test.com',
} as unknown as RecordFilter,
};
const query = await service.create(args, {
...workspaceQueryBuilderOptions,
objectMetadataItem: {
...workspaceQueryBuilderOptions.objectMetadataItem,
nameSingular: 'person',
},
});
expect(query.trim()).toEqual(`query {
personCollection(filter: {or:[{email:{eq:"test@test.com"}}]}) {
fieldsString
}
}`);
});
it('should return (first: 0) as a filter when only firstName is present', async () => {
argAliasCreate.mockReturnValue({
nameFirstName: 'John',
});
const args: FindDuplicatesResolverArgs<RecordFilter> = {
data: {
name: {
firstName: 'John',
},
} as unknown as RecordFilter,
};
const query = await service.create(args, {
...workspaceQueryBuilderOptions,
objectMetadataItem: {
...workspaceQueryBuilderOptions.objectMetadataItem,
nameSingular: 'person',
},
});
expect(query.trim()).toEqual(`query {
personCollection(first: 0) {
fieldsString
}
}`);
});
it('should use "currentRecord" as query args when its present', async () => {
argAliasCreate.mockReturnValue({
nameFirstName: 'John',
});
const args: FindDuplicatesResolverArgs<RecordFilter> = {
id: 'uuid',
};
const query = await service.create(
args,
{
...workspaceQueryBuilderOptions,
objectMetadataItem: {
...workspaceQueryBuilderOptions.objectMetadataItem,
nameSingular: 'person',
},
},
{
nameFirstName: 'Peter',
nameLastName: 'Parker',
},
);
expect(query.trim()).toEqual(`query {
personCollection(filter: {id:{neq:\"uuid\"},or:[{nameFirstName:{eq:\"Peter\"},nameLastName:{eq:\"Parker\"}}]}) {
fieldsString
}
}`);
});
});
describe('buildQueryForExistingRecord', () => {
it(`should include all the fields that exist for person inside "duplicateCriteriaCollection" constant`, async () => {
const query = service.buildQueryForExistingRecord('uuid', {
...workspaceQueryBuilderOptions,
objectMetadataItem: {
...workspaceQueryBuilderOptions.objectMetadataItem,
nameSingular: 'person',
},
});
expect(query.trim()).toEqual(`query {
personCollection(filter: { id: { eq: \"uuid\" }}){
edges {
node {
__typename
nameFirstName
nameLastName
linkedinLinkUrl
email
}
}
}
}`);
});
});
});

View File

@ -0,0 +1,89 @@
import {
RecordPositionQueryFactory,
RecordPositionQueryType,
} from 'src/engine/graphql/workspace-query-builder/factories/record-position-query.factory';
describe('RecordPositionQueryFactory', () => {
const objectMetadataItem = {
isCustom: false,
nameSingular: 'company',
};
const dataSourceSchema = 'workspace_test';
const factory: RecordPositionQueryFactory = new RecordPositionQueryFactory();
it('should be defined', () => {
expect(factory).toBeDefined();
});
describe('create', () => {
describe('createForGet', () => {
it('should return a string with the position when positionValue is first', async () => {
const positionValue = 'first';
const result = await factory.create(
RecordPositionQueryType.GET,
positionValue,
objectMetadataItem,
dataSourceSchema,
);
expect(result).toEqual(
`SELECT position FROM workspace_test."company"
WHERE "position" IS NOT NULL ORDER BY "position" ASC LIMIT 1`,
);
});
it('should return a string with the position when positionValue is last', async () => {
const positionValue = 'last';
const result = await factory.create(
RecordPositionQueryType.GET,
positionValue,
objectMetadataItem,
dataSourceSchema,
);
expect(result).toEqual(
`SELECT position FROM workspace_test."company"
WHERE "position" IS NOT NULL ORDER BY "position" DESC LIMIT 1`,
);
});
});
it('should return a string with the position when positionValue is a number', async () => {
const positionValue = 1;
try {
await factory.create(
RecordPositionQueryType.GET,
positionValue,
objectMetadataItem,
dataSourceSchema,
);
} catch (error) {
expect(error.message).toEqual(
'RecordPositionQueryType.GET requires positionValue to be a number',
);
}
});
});
describe('createForUpdate', () => {
it('should return a string when RecordPositionQueryType is UPDATE', async () => {
const positionValue = 1;
const result = await factory.create(
RecordPositionQueryType.UPDATE,
positionValue,
objectMetadataItem,
dataSourceSchema,
);
expect(result).toEqual(
`UPDATE workspace_test."company"
SET "position" = $1
WHERE "id" = $2`,
);
});
});
});

View File

@ -0,0 +1,69 @@
import { Injectable } from '@nestjs/common';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
@Injectable()
export class ArgsAliasFactory {
create(
args: Record<string, any>,
fieldMetadataCollection: FieldMetadataInterface[],
): Record<string, any> {
const fieldMetadataMap = new Map(
fieldMetadataCollection.map((fieldMetadata) => [
fieldMetadata.name,
fieldMetadata,
]),
);
return this.createArgsObjectRecursive(args, fieldMetadataMap);
}
private createArgsObjectRecursive(
args: Record<string, any>,
fieldMetadataMap: Map<string, FieldMetadataInterface>,
) {
// If it's not an object, we don't need to do anything
if (typeof args !== 'object' || args === null) {
return args;
}
// If it's an array, we need to map all items
if (Array.isArray(args)) {
return args.map((arg) =>
this.createArgsObjectRecursive(arg, fieldMetadataMap),
);
}
const newArgs = {};
for (const [key, value] of Object.entries(args)) {
const fieldMetadata = fieldMetadataMap.get(key);
// If it's a special complex field, we need to map all columns
if (
fieldMetadata &&
typeof value === 'object' &&
value !== null &&
Object.values(fieldMetadata.targetColumnMap).length > 1
) {
for (const [subKey, subValue] of Object.entries(value)) {
const mappedKey = fieldMetadata.targetColumnMap[subKey];
if (mappedKey) {
newArgs[mappedKey] = subValue;
}
}
} else if (fieldMetadata) {
// Otherwise we just need to map the value
const mappedKey = fieldMetadata.targetColumnMap.value;
newArgs[mappedKey ?? key] = value;
} else {
// Recurse if value is a nested object, otherwise append field or alias
newArgs[key] = this.createArgsObjectRecursive(value, fieldMetadataMap);
}
}
return newArgs;
}
}

View File

@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import { stringifyWithoutKeyQuote } from 'src/engine/graphql/workspace-query-builder/utils/stringify-without-key-quote.util';
import { ArgsAliasFactory } from './args-alias.factory';
@Injectable()
export class ArgsStringFactory {
constructor(private readonly argsAliasFactory: ArgsAliasFactory) {}
create(
initialArgs: Record<string, any> | undefined,
fieldMetadataCollection: FieldMetadataInterface[],
): string | null {
if (!initialArgs) {
return null;
}
let argsString = '';
const computedArgs = this.argsAliasFactory.create(
initialArgs,
fieldMetadataCollection,
);
for (const key in computedArgs) {
// Check if the value is not undefined
if (computedArgs[key] === undefined) {
continue;
}
if (typeof computedArgs[key] === 'string') {
// If it's a string, add quotes
argsString += `${key}: "${computedArgs[key]}", `;
} else if (
typeof computedArgs[key] === 'object' &&
computedArgs[key] !== null
) {
// If it's an object (and not null), stringify it
argsString += `${key}: ${this.buildStringifiedObject(
key,
computedArgs[key],
)}, `;
} else {
// For other types (number, boolean), add as is
argsString += `${key}: ${computedArgs[key]}, `;
}
}
// Remove trailing comma and space, if present
if (argsString.endsWith(', ')) {
argsString = argsString.slice(0, -2);
}
return argsString;
}
private buildStringifiedObject(
key: string,
obj: Record<string, any>,
): string {
// PgGraphql is expecting the orderBy argument to be an array of objects
if (key === 'orderBy') {
const orderByString = Object.keys(obj)
.sort((_, b) => {
return b === 'position' ? -1 : 0;
})
.map((orderByKey) => `{${orderByKey}: ${obj[orderByKey]}}`)
.join(', ');
return `[${orderByString}]`;
}
return stringifyWithoutKeyQuote(obj);
}
}

View File

@ -0,0 +1,56 @@
import { Injectable, Logger } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import { WorkspaceQueryBuilderOptions } from 'src/engine/graphql/workspace-query-builder/interfaces/workspace-query-builder-options.interface';
import { Record as IRecord } from 'src/engine/graphql/workspace-query-builder/interfaces/record.interface';
import { CreateManyResolverArgs } from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { stringifyWithoutKeyQuote } from 'src/engine/graphql/workspace-query-builder/utils/stringify-without-key-quote.util';
import { computeObjectTargetTable } from 'src/engine-workspace/utils/compute-object-target-table.util';
import { FieldsStringFactory } from './fields-string.factory';
import { ArgsAliasFactory } from './args-alias.factory';
@Injectable()
export class CreateManyQueryFactory {
private readonly logger = new Logger(CreateManyQueryFactory.name);
constructor(
private readonly fieldsStringFactory: FieldsStringFactory,
private readonly argsAliasFactory: ArgsAliasFactory,
) {}
async create<Record extends IRecord = IRecord>(
args: CreateManyResolverArgs<Record>,
options: WorkspaceQueryBuilderOptions,
) {
const fieldsString = await this.fieldsStringFactory.create(
options.info,
options.fieldMetadataCollection,
options.objectMetadataCollection,
);
const computedArgs = this.argsAliasFactory.create(
args,
options.fieldMetadataCollection,
);
return `
mutation {
insertInto${computeObjectTargetTable(
options.objectMetadataItem,
)}Collection(objects: ${stringifyWithoutKeyQuote(
computedArgs.data.map((datum) => ({
id: uuidv4(),
...datum,
})),
)}) {
affectedCount
records {
${fieldsString}
}
}
}
`;
}
}

View File

@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import { WorkspaceQueryBuilderOptions } from 'src/engine/graphql/workspace-query-builder/interfaces/workspace-query-builder-options.interface';
import { DeleteManyResolverArgs } from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { stringifyWithoutKeyQuote } from 'src/engine/graphql/workspace-query-builder/utils/stringify-without-key-quote.util';
import { computeObjectTargetTable } from 'src/engine-workspace/utils/compute-object-target-table.util';
import { FieldsStringFactory } from './fields-string.factory';
export interface DeleteManyQueryFactoryOptions
extends WorkspaceQueryBuilderOptions {
atMost?: number;
}
@Injectable()
export class DeleteManyQueryFactory {
constructor(private readonly fieldsStringFactory: FieldsStringFactory) {}
async create(
args: DeleteManyResolverArgs,
options: DeleteManyQueryFactoryOptions,
) {
const fieldsString = await this.fieldsStringFactory.create(
options.info,
options.fieldMetadataCollection,
options.objectMetadataCollection,
);
return `
mutation {
deleteFrom${computeObjectTargetTable(
options.objectMetadataItem,
)}Collection(filter: ${stringifyWithoutKeyQuote(
args.filter,
)}, atMost: ${options.atMost ?? 1}) {
affectedCount
records {
${fieldsString}
}
}
}
`;
}
}

View File

@ -0,0 +1,39 @@
import { Injectable, Logger } from '@nestjs/common';
import { WorkspaceQueryBuilderOptions } from 'src/engine/graphql/workspace-query-builder/interfaces/workspace-query-builder-options.interface';
import { DeleteOneResolverArgs } from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { computeObjectTargetTable } from 'src/engine-workspace/utils/compute-object-target-table.util';
import { FieldsStringFactory } from './fields-string.factory';
@Injectable()
export class DeleteOneQueryFactory {
private readonly logger = new Logger(DeleteOneQueryFactory.name);
constructor(private readonly fieldsStringFactory: FieldsStringFactory) {}
async create(
args: DeleteOneResolverArgs,
options: WorkspaceQueryBuilderOptions,
) {
const fieldsString = await this.fieldsStringFactory.create(
options.info,
options.fieldMetadataCollection,
options.objectMetadataCollection,
);
return `
mutation {
deleteFrom${computeObjectTargetTable(
options.objectMetadataItem,
)}Collection(filter: { id: { eq: "${args.id}" } }) {
affectedCount
records {
${fieldsString}
}
}
}
`;
}
}

View File

@ -0,0 +1,31 @@
import { ArgsAliasFactory } from './args-alias.factory';
import { ArgsStringFactory } from './args-string.factory';
import { RelationFieldAliasFactory } from './relation-field-alias.factory';
import { CreateManyQueryFactory } from './create-many-query.factory';
import { DeleteOneQueryFactory } from './delete-one-query.factory';
import { FieldAliasFactory } from './field-alias.factory';
import { FieldsStringFactory } from './fields-string.factory';
import { FindManyQueryFactory } from './find-many-query.factory';
import { FindOneQueryFactory } from './find-one-query.factory';
import { UpdateOneQueryFactory } from './update-one-query.factory';
import { UpdateManyQueryFactory } from './update-many-query.factory';
import { DeleteManyQueryFactory } from './delete-many-query.factory';
import { FindDuplicatesQueryFactory } from './find-duplicates-query.factory';
import { RecordPositionQueryFactory } from './record-position-query.factory';
export const workspaceQueryBuilderFactories = [
ArgsAliasFactory,
ArgsStringFactory,
RelationFieldAliasFactory,
CreateManyQueryFactory,
DeleteOneQueryFactory,
FieldAliasFactory,
FieldsStringFactory,
FindManyQueryFactory,
FindOneQueryFactory,
FindDuplicatesQueryFactory,
RecordPositionQueryFactory,
UpdateOneQueryFactory,
UpdateManyQueryFactory,
DeleteManyQueryFactory,
];

View File

@ -0,0 +1,30 @@
import { Injectable, Logger } from '@nestjs/common';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
@Injectable()
export class FieldAliasFactory {
private readonly logger = new Logger(FieldAliasFactory.name);
create(fieldKey: string, fieldMetadata: FieldMetadataInterface) {
const entries = Object.entries(fieldMetadata.targetColumnMap);
if (entries.length === 0) {
return null;
}
if (entries.length === 1) {
// If there is only one value, use it as the alias
const alias = entries[0][1];
return `${fieldKey}: ${alias}`;
}
// Otherwise it means it's a special type with multiple values, so we need map all columns
return `
${entries
.map(([key, value]) => `___${fieldMetadata.name}_${key}: ${value}`)
.join('\n')}
`;
}
}

View File

@ -0,0 +1,103 @@
import { Injectable, Logger } from '@nestjs/common';
import { GraphQLResolveInfo } from 'graphql';
import graphqlFields from 'graphql-fields';
import isEmpty from 'lodash.isempty';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { isRelationFieldMetadataType } from 'src/engine-workspace/utils/is-relation-field-metadata-type.util';
import { FieldAliasFactory } from './field-alias.factory';
import { RelationFieldAliasFactory } from './relation-field-alias.factory';
@Injectable()
export class FieldsStringFactory {
private readonly logger = new Logger(FieldsStringFactory.name);
constructor(
private readonly fieldAliasFactory: FieldAliasFactory,
private readonly relationFieldAliasFactory: RelationFieldAliasFactory,
) {}
create(
info: GraphQLResolveInfo,
fieldMetadataCollection: FieldMetadataInterface[],
objectMetadataCollection: ObjectMetadataInterface[],
): Promise<string> {
const selectedFields: Record<string, any> = graphqlFields(info);
return this.createFieldsStringRecursive(
info,
selectedFields,
fieldMetadataCollection,
objectMetadataCollection,
);
}
async createFieldsStringRecursive(
info: GraphQLResolveInfo,
selectedFields: Record<string, any>,
fieldMetadataCollection: FieldMetadataInterface[],
objectMetadataCollection: ObjectMetadataInterface[],
accumulator = '',
): Promise<string> {
const fieldMetadataMap = new Map(
fieldMetadataCollection.map((metadata) => [metadata.name, metadata]),
);
for (const [fieldKey, fieldValue] of Object.entries(selectedFields)) {
let fieldAlias: string | null;
if (fieldMetadataMap.has(fieldKey)) {
// We're sure that the field exists in the map after this if condition
// ES6 should tackle that more properly
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const fieldMetadata = fieldMetadataMap.get(fieldKey)!;
// If the field is a relation field, we need to create a special alias
if (isRelationFieldMetadataType(fieldMetadata.type)) {
const alias = await this.relationFieldAliasFactory.create(
fieldKey,
fieldValue,
fieldMetadata,
objectMetadataCollection,
info,
);
fieldAlias = alias;
} else {
// Otherwise we just need to create a simple alias
const alias = this.fieldAliasFactory.create(fieldKey, fieldMetadata);
fieldAlias = alias;
}
}
fieldAlias ??= fieldKey;
// Recurse if value is a nested object, otherwise append field or alias
if (
!fieldMetadataMap.has(fieldKey) &&
fieldValue &&
typeof fieldValue === 'object' &&
!isEmpty(fieldValue)
) {
accumulator += `${fieldKey} {\n`;
accumulator = await this.createFieldsStringRecursive(
info,
fieldValue,
fieldMetadataCollection,
objectMetadataCollection,
accumulator,
);
accumulator += `}\n`;
} else {
accumulator += `${fieldAlias}\n`;
}
}
return accumulator;
}
}

View File

@ -0,0 +1,153 @@
import { Injectable, Logger } from '@nestjs/common';
import isEmpty from 'lodash.isempty';
import { WorkspaceQueryBuilderOptions } from 'src/engine/graphql/workspace-query-builder/interfaces/workspace-query-builder-options.interface';
import { RecordFilter } from 'src/engine/graphql/workspace-query-builder/interfaces/record.interface';
import { FindDuplicatesResolverArgs } from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { computeObjectTargetTable } from 'src/engine-workspace/utils/compute-object-target-table.util';
import { stringifyWithoutKeyQuote } from 'src/engine/graphql/workspace-query-builder/utils/stringify-without-key-quote.util';
import { ArgsAliasFactory } from 'src/engine/graphql/workspace-query-builder/factories/args-alias.factory';
import { duplicateCriteriaCollection } from 'src/engine/graphql/workspace-resolver-builder/constants/duplicate-criteria.constants';
import { settings } from 'src/engine/constants/settings';
import { FieldsStringFactory } from './fields-string.factory';
@Injectable()
export class FindDuplicatesQueryFactory {
private readonly logger = new Logger(FindDuplicatesQueryFactory.name);
constructor(
private readonly fieldsStringFactory: FieldsStringFactory,
private readonly argsAliasFactory: ArgsAliasFactory,
) {}
async create<Filter extends RecordFilter = RecordFilter>(
args: FindDuplicatesResolverArgs<Filter>,
options: WorkspaceQueryBuilderOptions,
currentRecord?: Record<string, unknown>,
) {
const fieldsString = await this.fieldsStringFactory.create(
options.info,
options.fieldMetadataCollection,
options.objectMetadataCollection,
);
const argsData = this.getFindDuplicateBy<Filter>(
args,
options,
currentRecord,
);
const duplicateCondition = this.buildDuplicateCondition(
options.objectMetadataItem,
argsData,
args.id,
);
const filters = stringifyWithoutKeyQuote(duplicateCondition);
return `
query {
${computeObjectTargetTable(options.objectMetadataItem)}Collection${
isEmpty(duplicateCondition?.or)
? '(first: 0)'
: `(filter: ${filters})`
} {
${fieldsString}
}
}
`;
}
getFindDuplicateBy<Filter extends RecordFilter = RecordFilter>(
args: FindDuplicatesResolverArgs<Filter>,
options: WorkspaceQueryBuilderOptions,
currentRecord?: Record<string, unknown>,
) {
if (currentRecord) {
return currentRecord;
}
return this.argsAliasFactory.create(
args.data ?? {},
options.fieldMetadataCollection,
);
}
buildQueryForExistingRecord(
id: string,
options: WorkspaceQueryBuilderOptions,
) {
return `
query {
${computeObjectTargetTable(
options.objectMetadataItem,
)}Collection(filter: { id: { eq: "${id}" }}){
edges {
node {
__typename
${this.getApplicableDuplicateCriteriaCollection(
options.objectMetadataItem,
)
.flatMap((dc) => dc.columnNames)
.join('\n')}
}
}
}
}
`;
}
private buildDuplicateCondition(
objectMetadataItem: ObjectMetadataInterface,
argsData?: Record<string, unknown>,
filteringByExistingRecordId?: string,
) {
if (!argsData) {
return;
}
const criteriaCollection =
this.getApplicableDuplicateCriteriaCollection(objectMetadataItem);
const criteriaWithMatchingArgs = criteriaCollection.filter((criteria) =>
criteria.columnNames.every((columnName) => {
const value = argsData[columnName] as string | undefined;
return (
!!value && value.length >= settings.minLengthOfStringForDuplicateCheck
);
}),
);
const filterCriteria = criteriaWithMatchingArgs.map((criteria) =>
Object.fromEntries(
criteria.columnNames.map((columnName) => [
columnName,
{ eq: argsData[columnName] },
]),
),
);
return {
// when filtering by an existing record, we need to filter that explicit record out
...(filteringByExistingRecordId && {
id: { neq: filteringByExistingRecordId },
}),
// keep condition as "or" to get results by more duplicate criteria
or: filterCriteria,
};
}
private getApplicableDuplicateCriteriaCollection(
objectMetadataItem: ObjectMetadataInterface,
) {
return duplicateCriteriaCollection.filter(
(duplicateCriteria) =>
duplicateCriteria.objectName === objectMetadataItem.nameSingular,
);
}
}

View File

@ -0,0 +1,51 @@
import { Injectable, Logger } from '@nestjs/common';
import { WorkspaceQueryBuilderOptions } from 'src/engine/graphql/workspace-query-builder/interfaces/workspace-query-builder-options.interface';
import {
RecordFilter,
RecordOrderBy,
} from 'src/engine/graphql/workspace-query-builder/interfaces/record.interface';
import { FindManyResolverArgs } from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { computeObjectTargetTable } from 'src/engine-workspace/utils/compute-object-target-table.util';
import { ArgsStringFactory } from './args-string.factory';
import { FieldsStringFactory } from './fields-string.factory';
@Injectable()
export class FindManyQueryFactory {
private readonly logger = new Logger(FindManyQueryFactory.name);
constructor(
private readonly fieldsStringFactory: FieldsStringFactory,
private readonly argsStringFactory: ArgsStringFactory,
) {}
async create<
Filter extends RecordFilter = RecordFilter,
OrderBy extends RecordOrderBy = RecordOrderBy,
>(
args: FindManyResolverArgs<Filter, OrderBy>,
options: WorkspaceQueryBuilderOptions,
) {
const fieldsString = await this.fieldsStringFactory.create(
options.info,
options.fieldMetadataCollection,
options.objectMetadataCollection,
);
const argsString = this.argsStringFactory.create(
args,
options.fieldMetadataCollection,
);
return `
query {
${computeObjectTargetTable(options.objectMetadataItem)}Collection${
argsString ? `(${argsString})` : ''
} {
${fieldsString}
}
}
`;
}
}

View File

@ -0,0 +1,49 @@
import { Injectable, Logger } from '@nestjs/common';
import { WorkspaceQueryBuilderOptions } from 'src/engine/graphql/workspace-query-builder/interfaces/workspace-query-builder-options.interface';
import { RecordFilter } from 'src/engine/graphql/workspace-query-builder/interfaces/record.interface';
import { FindOneResolverArgs } from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { computeObjectTargetTable } from 'src/engine-workspace/utils/compute-object-target-table.util';
import { ArgsStringFactory } from './args-string.factory';
import { FieldsStringFactory } from './fields-string.factory';
@Injectable()
export class FindOneQueryFactory {
private readonly logger = new Logger(FindOneQueryFactory.name);
constructor(
private readonly fieldsStringFactory: FieldsStringFactory,
private readonly argsStringFactory: ArgsStringFactory,
) {}
async create<Filter extends RecordFilter = RecordFilter>(
args: FindOneResolverArgs<Filter>,
options: WorkspaceQueryBuilderOptions,
) {
const fieldsString = await this.fieldsStringFactory.create(
options.info,
options.fieldMetadataCollection,
options.objectMetadataCollection,
);
const argsString = this.argsStringFactory.create(
args,
options.fieldMetadataCollection,
);
return `
query {
${computeObjectTargetTable(options.objectMetadataItem)}Collection${
argsString ? `(${argsString})` : ''
} {
edges {
node {
${fieldsString}
}
}
}
}
`;
}
}

View File

@ -0,0 +1,54 @@
import { Injectable } from '@nestjs/common';
export enum RecordPositionQueryType {
GET = 'GET',
UPDATE = 'UPDATE',
}
@Injectable()
export class RecordPositionQueryFactory {
async create(
recordPositionQueryType: RecordPositionQueryType,
positionValue: 'first' | 'last' | number,
objectMetadata: { isCustom: boolean; nameSingular: string },
dataSourceSchema: string,
): Promise<string> {
const name =
(objectMetadata.isCustom ? '_' : '') + objectMetadata.nameSingular;
switch (recordPositionQueryType) {
case RecordPositionQueryType.GET:
if (typeof positionValue === 'number') {
throw new Error(
'RecordPositionQueryType.GET requires positionValue to be a number',
);
}
return this.createForGet(positionValue, name, dataSourceSchema);
case RecordPositionQueryType.UPDATE:
return this.createForUpdate(name, dataSourceSchema);
default:
throw new Error('Invalid RecordPositionQueryType');
}
}
private async createForGet(
positionValue: 'first' | 'last',
name: string,
dataSourceSchema: string,
): Promise<string> {
const orderByDirection = positionValue === 'first' ? 'ASC' : 'DESC';
return `SELECT position FROM ${dataSourceSchema}."${name}"
WHERE "position" IS NOT NULL ORDER BY "position" ${orderByDirection} LIMIT 1`;
}
private async createForUpdate(
name: string,
dataSourceSchema: string,
): Promise<string> {
return `UPDATE ${dataSourceSchema}."${name}"
SET "position" = $1
WHERE "id" = $2`;
}
}

View File

@ -0,0 +1,150 @@
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { GraphQLResolveInfo } from 'graphql';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { isRelationFieldMetadataType } from 'src/engine-workspace/utils/is-relation-field-metadata-type.util';
import { RelationMetadataType } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
import {
deduceRelationDirection,
RelationDirection,
} from 'src/engine-workspace/utils/deduce-relation-direction.util';
import { getFieldArgumentsByKey } from 'src/engine/graphql/workspace-query-builder/utils/get-field-arguments-by-key.util';
import { ObjectMetadataService } from 'src/engine-metadata/object-metadata/object-metadata.service';
import { computeObjectTargetTable } from 'src/engine-workspace/utils/compute-object-target-table.util';
import { FieldsStringFactory } from './fields-string.factory';
import { ArgsStringFactory } from './args-string.factory';
@Injectable()
export class RelationFieldAliasFactory {
private logger = new Logger(RelationFieldAliasFactory.name);
constructor(
@Inject(forwardRef(() => FieldsStringFactory))
private readonly fieldsStringFactory: FieldsStringFactory,
private readonly argsStringFactory: ArgsStringFactory,
private readonly objectMetadataService: ObjectMetadataService,
) {}
create(
fieldKey: string,
fieldValue: any,
fieldMetadata: FieldMetadataInterface,
objectMetadataCollection: ObjectMetadataInterface[],
info: GraphQLResolveInfo,
): Promise<string> {
if (!isRelationFieldMetadataType(fieldMetadata.type)) {
throw new Error(`Field ${fieldMetadata.name} is not a relation field`);
}
return this.createRelationAlias(
fieldKey,
fieldValue,
fieldMetadata,
objectMetadataCollection,
info,
);
}
private async createRelationAlias(
fieldKey: string,
fieldValue: any,
fieldMetadata: FieldMetadataInterface,
objectMetadataCollection: ObjectMetadataInterface[],
info: GraphQLResolveInfo,
): Promise<string> {
const relationMetadata =
fieldMetadata.fromRelationMetadata ?? fieldMetadata.toRelationMetadata;
if (!relationMetadata) {
throw new Error(
`Relation metadata not found for field ${fieldMetadata.name}`,
);
}
if (!fieldMetadata.workspaceId) {
throw new Error(
`Workspace id not found for field ${fieldMetadata.name} in object metadata ${fieldMetadata.objectMetadataId}`,
);
}
const relationDirection = deduceRelationDirection(
fieldMetadata,
relationMetadata,
);
// Retrieve the referenced object metadata based on the relation direction
// Mandatory to handle n+n relations
const referencedObjectMetadata = objectMetadataCollection.find(
(objectMetadata) =>
objectMetadata.id ===
(relationDirection == RelationDirection.TO
? relationMetadata.fromObjectMetadataId
: relationMetadata.toObjectMetadataId),
);
if (!referencedObjectMetadata) {
throw new Error(
`Referenced object metadata not found for relation ${relationMetadata.id}`,
);
}
// If it's a relation destination is of kind MANY, we need to add the collection suffix and extract the args
if (
relationMetadata.relationType === RelationMetadataType.ONE_TO_MANY &&
relationDirection === RelationDirection.FROM
) {
const args = getFieldArgumentsByKey(info, fieldKey);
const argsString = this.argsStringFactory.create(
args,
referencedObjectMetadata.fields ?? [],
);
const fieldsString =
await this.fieldsStringFactory.createFieldsStringRecursive(
info,
fieldValue,
referencedObjectMetadata.fields ?? [],
objectMetadataCollection,
);
return `
${fieldKey}: ${computeObjectTargetTable(
referencedObjectMetadata,
)}Collection${argsString ? `(${argsString})` : ''} {
${fieldsString}
}
`;
}
let relationAlias = fieldMetadata.isCustom
? `${fieldKey}: _${fieldMetadata.name}`
: fieldKey;
// For one to one relations, pg_graphql use the target TableName on the side that is not storing the foreign key
// so we need to alias it to the field key
if (
relationMetadata.relationType === RelationMetadataType.ONE_TO_ONE &&
relationDirection === RelationDirection.FROM
) {
relationAlias = `${fieldKey}: ${computeObjectTargetTable(
referencedObjectMetadata,
)}`;
}
const fieldsString =
await this.fieldsStringFactory.createFieldsStringRecursive(
info,
fieldValue,
referencedObjectMetadata.fields ?? [],
objectMetadataCollection,
);
// Otherwise it means it's a relation destination is of kind ONE
return `
${relationAlias} {
${fieldsString}
}
`;
}
}

View File

@ -0,0 +1,64 @@
import { Injectable } from '@nestjs/common';
import {
Record as IRecord,
RecordFilter,
} from 'src/engine/graphql/workspace-query-builder/interfaces/record.interface';
import { WorkspaceQueryBuilderOptions } from 'src/engine/graphql/workspace-query-builder/interfaces/workspace-query-builder-options.interface';
import { UpdateManyResolverArgs } from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { stringifyWithoutKeyQuote } from 'src/engine/graphql/workspace-query-builder/utils/stringify-without-key-quote.util';
import { FieldsStringFactory } from 'src/engine/graphql/workspace-query-builder/factories/fields-string.factory';
import { ArgsAliasFactory } from 'src/engine/graphql/workspace-query-builder/factories/args-alias.factory';
import { computeObjectTargetTable } from 'src/engine-workspace/utils/compute-object-target-table.util';
export interface UpdateManyQueryFactoryOptions
extends WorkspaceQueryBuilderOptions {
atMost?: number;
}
@Injectable()
export class UpdateManyQueryFactory {
constructor(
private readonly fieldsStringFactory: FieldsStringFactory,
private readonly argsAliasFactory: ArgsAliasFactory,
) {}
async create<
Record extends IRecord = IRecord,
Filter extends RecordFilter = RecordFilter,
>(
args: UpdateManyResolverArgs<Record, Filter>,
options: UpdateManyQueryFactoryOptions,
) {
const fieldsString = await this.fieldsStringFactory.create(
options.info,
options.fieldMetadataCollection,
options.objectMetadataCollection,
);
const computedArgs = this.argsAliasFactory.create(
args,
options.fieldMetadataCollection,
);
const argsData = {
...computedArgs.data,
updatedAt: new Date().toISOString(),
};
return `
mutation {
update${computeObjectTargetTable(options.objectMetadataItem)}Collection(
set: ${stringifyWithoutKeyQuote(argsData)},
filter: ${stringifyWithoutKeyQuote(args.filter)},
atMost: ${options.atMost ?? 1},
) {
affectedCount
records {
${fieldsString}
}
}
}`;
}
}

View File

@ -0,0 +1,56 @@
import { Injectable, Logger } from '@nestjs/common';
import { WorkspaceQueryBuilderOptions } from 'src/engine/graphql/workspace-query-builder/interfaces/workspace-query-builder-options.interface';
import { Record as IRecord } from 'src/engine/graphql/workspace-query-builder/interfaces/record.interface';
import { UpdateOneResolverArgs } from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { stringifyWithoutKeyQuote } from 'src/engine/graphql/workspace-query-builder/utils/stringify-without-key-quote.util';
import { computeObjectTargetTable } from 'src/engine-workspace/utils/compute-object-target-table.util';
import { FieldsStringFactory } from './fields-string.factory';
import { ArgsAliasFactory } from './args-alias.factory';
@Injectable()
export class UpdateOneQueryFactory {
private readonly logger = new Logger(UpdateOneQueryFactory.name);
constructor(
private readonly fieldsStringFactory: FieldsStringFactory,
private readonly argsAliasFactory: ArgsAliasFactory,
) {}
async create<Record extends IRecord = IRecord>(
args: UpdateOneResolverArgs<Record>,
options: WorkspaceQueryBuilderOptions,
) {
const fieldsString = await this.fieldsStringFactory.create(
options.info,
options.fieldMetadataCollection,
options.objectMetadataCollection,
);
const computedArgs = this.argsAliasFactory.create(
args,
options.fieldMetadataCollection,
);
const argsData = {
...computedArgs.data,
updatedAt: new Date().toISOString(),
};
return `
mutation {
update${computeObjectTargetTable(
options.objectMetadataItem,
)}Collection(set: ${stringifyWithoutKeyQuote(
argsData,
)}, filter: { id: { eq: "${computedArgs.id}" } }) {
affectedCount
records {
${fieldsString}
}
}
}
`;
}
}

View File

@ -0,0 +1,26 @@
export interface Record {
id: string;
[key: string]: any;
createdAt: string;
updatedAt: string;
}
export type RecordFilter = {
[Property in keyof Record]: any;
};
export enum OrderByDirection {
AscNullsFirst = 'AscNullsFirst',
AscNullsLast = 'AscNullsLast',
DescNullsFirst = 'DescNullsFirst',
DescNullsLast = 'DescNullsLast',
}
export type RecordOrderBy = {
[Property in keyof Record]?: OrderByDirection;
};
export interface RecordDuplicateCriteria {
objectName: string;
columnNames: string[];
}

View File

@ -0,0 +1,11 @@
import { GraphQLResolveInfo } from 'graphql';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
export interface WorkspaceQueryBuilderOptions {
objectMetadataItem: ObjectMetadataInterface;
info: GraphQLResolveInfo;
fieldMetadataCollection: FieldMetadataInterface[];
objectMetadataCollection: ObjectMetadataInterface[];
}

View File

@ -0,0 +1,13 @@
import { GraphQLResolveInfo } from 'graphql';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { WorkspaceQueryBuilderOptions } from 'src/engine/graphql/workspace-query-builder/interfaces/workspace-query-builder-options.interface';
import { objectMetadataItem } from 'src/utils/utils-test/object-metadata-item';
export const workspaceQueryBuilderOptions: WorkspaceQueryBuilderOptions = {
fieldMetadataCollection: [],
info: {} as GraphQLResolveInfo,
objectMetadataCollection: [],
objectMetadataItem: objectMetadataItem as ObjectMetadataInterface,
};

View File

@ -0,0 +1,58 @@
import { stringifyWithoutKeyQuote } from 'src/engine/graphql/workspace-query-builder/utils/stringify-without-key-quote.util';
describe('stringifyWithoutKeyQuote', () => {
test('should stringify object correctly without quotes around keys', () => {
const obj = { name: 'John', age: 30, isAdmin: false };
const result = stringifyWithoutKeyQuote(obj);
expect(result).toBe('{name:"John",age:30,isAdmin:false}');
});
test('should handle nested objects', () => {
const obj = {
name: 'John',
age: 30,
address: { city: 'New York', zipCode: 10001 },
};
const result = stringifyWithoutKeyQuote(obj);
expect(result).toBe(
'{name:"John",age:30,address:{city:"New York",zipCode:10001}}',
);
});
test('should handle arrays', () => {
const obj = {
name: 'John',
age: 30,
hobbies: ['reading', 'movies', 'hiking'],
};
const result = stringifyWithoutKeyQuote(obj);
expect(result).toBe(
'{name:"John",age:30,hobbies:["reading","movies","hiking"]}',
);
});
test('should handle empty objects', () => {
const obj = {};
const result = stringifyWithoutKeyQuote(obj);
expect(result).toBe('{}');
});
test('should handle numbers, strings, and booleans', () => {
const num = 10;
const str = 'Hello';
const bool = false;
expect(stringifyWithoutKeyQuote(num)).toBe('10');
expect(stringifyWithoutKeyQuote(str)).toBe('"Hello"');
expect(stringifyWithoutKeyQuote(bool)).toBe('false');
});
test('should handle null and undefined', () => {
expect(stringifyWithoutKeyQuote(null)).toBe('null');
expect(stringifyWithoutKeyQuote(undefined)).toBe(undefined);
});
});

View File

@ -0,0 +1,96 @@
import {
GraphQLResolveInfo,
SelectionSetNode,
Kind,
SelectionNode,
FieldNode,
InlineFragmentNode,
ValueNode,
} from 'graphql';
const isFieldNode = (node: SelectionNode): node is FieldNode =>
node.kind === Kind.FIELD;
const isInlineFragmentNode = (
node: SelectionNode,
): node is InlineFragmentNode => node.kind === Kind.INLINE_FRAGMENT;
const findFieldNode = (
selectionSet: SelectionSetNode | undefined,
key: string,
): FieldNode | null => {
if (!selectionSet) return null;
let field: FieldNode | null = null;
for (const selection of selectionSet.selections) {
// We've found the field
if (isFieldNode(selection) && selection.name.value === key) {
return selection;
}
// Recursively search for the field in nested selections
if (
(isFieldNode(selection) || isInlineFragmentNode(selection)) &&
selection.selectionSet
) {
field = findFieldNode(selection.selectionSet, key);
// If we find the field in a nested selection, stop searching
if (field) break;
}
}
return field;
};
const parseValueNode = (
valueNode: ValueNode,
variables: GraphQLResolveInfo['variableValues'],
) => {
switch (valueNode.kind) {
case Kind.VARIABLE:
return variables[valueNode.name.value];
case Kind.INT:
case Kind.FLOAT:
return Number(valueNode.value);
case Kind.STRING:
case Kind.BOOLEAN:
case Kind.ENUM:
return valueNode.value;
case Kind.LIST:
return valueNode.values.map((value) => parseValueNode(value, variables));
case Kind.OBJECT:
return valueNode.fields.reduce((obj, field) => {
obj[field.name.value] = parseValueNode(field.value, variables);
return obj;
}, {});
default:
return null;
}
};
export const getFieldArgumentsByKey = (
info: GraphQLResolveInfo,
fieldKey: string,
): Record<string, any> => {
// Start from the first top-level field node and search recursively
const targetField = findFieldNode(info.fieldNodes[0].selectionSet, fieldKey);
// If the field is not found, throw an error
if (!targetField) {
throw new Error(`Field "${fieldKey}" not found.`);
}
// Extract the arguments from the field we've found
const args: Record<string, any> = {};
if (targetField.arguments && targetField.arguments.length) {
for (const arg of targetField.arguments) {
args[arg.name.value] = parseValueNode(arg.value, info.variableValues);
}
}
return args;
};

View File

@ -0,0 +1,6 @@
export const stringifyWithoutKeyQuote = (obj: any) => {
const jsonString = JSON.stringify(obj);
const jsonWithoutQuotes = jsonString?.replace(/"(\w+)"\s*:/g, '$1:');
return jsonWithoutQuotes;
};

View File

@ -0,0 +1,126 @@
import { Injectable, Logger } from '@nestjs/common';
import { WorkspaceQueryBuilderOptions } from 'src/engine/graphql/workspace-query-builder/interfaces/workspace-query-builder-options.interface';
import {
Record as IRecord,
RecordFilter,
RecordOrderBy,
} from 'src/engine/graphql/workspace-query-builder/interfaces/record.interface';
import {
FindManyResolverArgs,
FindOneResolverArgs,
CreateManyResolverArgs,
UpdateOneResolverArgs,
DeleteOneResolverArgs,
UpdateManyResolverArgs,
DeleteManyResolverArgs,
FindDuplicatesResolverArgs,
} from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { FindManyQueryFactory } from './factories/find-many-query.factory';
import { FindOneQueryFactory } from './factories/find-one-query.factory';
import { CreateManyQueryFactory } from './factories/create-many-query.factory';
import { UpdateOneQueryFactory } from './factories/update-one-query.factory';
import { DeleteOneQueryFactory } from './factories/delete-one-query.factory';
import {
UpdateManyQueryFactory,
UpdateManyQueryFactoryOptions,
} from './factories/update-many-query.factory';
import {
DeleteManyQueryFactory,
DeleteManyQueryFactoryOptions,
} from './factories/delete-many-query.factory';
import { FindDuplicatesQueryFactory } from './factories/find-duplicates-query.factory';
@Injectable()
export class WorkspaceQueryBuilderFactory {
private readonly logger = new Logger(WorkspaceQueryBuilderFactory.name);
constructor(
private readonly findManyQueryFactory: FindManyQueryFactory,
private readonly findOneQueryFactory: FindOneQueryFactory,
private readonly findDuplicatesQueryFactory: FindDuplicatesQueryFactory,
private readonly createManyQueryFactory: CreateManyQueryFactory,
private readonly updateOneQueryFactory: UpdateOneQueryFactory,
private readonly deleteOneQueryFactory: DeleteOneQueryFactory,
private readonly updateManyQueryFactory: UpdateManyQueryFactory,
private readonly deleteManyQueryFactory: DeleteManyQueryFactory,
) {}
findMany<
Filter extends RecordFilter = RecordFilter,
OrderBy extends RecordOrderBy = RecordOrderBy,
>(
args: FindManyResolverArgs<Filter, OrderBy>,
options: WorkspaceQueryBuilderOptions,
): Promise<string> {
return this.findManyQueryFactory.create<Filter, OrderBy>(args, options);
}
findOne<Filter extends RecordFilter = RecordFilter>(
args: FindOneResolverArgs<Filter>,
options: WorkspaceQueryBuilderOptions,
): Promise<string> {
return this.findOneQueryFactory.create<Filter>(args, options);
}
findDuplicates<Filter extends RecordFilter = RecordFilter>(
args: FindDuplicatesResolverArgs<Filter>,
options: WorkspaceQueryBuilderOptions,
existingRecord?: Record<string, unknown>,
): Promise<string> {
return this.findDuplicatesQueryFactory.create<Filter>(
args,
options,
existingRecord,
);
}
findDuplicatesExistingRecord(
id: string,
options: WorkspaceQueryBuilderOptions,
): string {
return this.findDuplicatesQueryFactory.buildQueryForExistingRecord(
id,
options,
);
}
createMany<Record extends IRecord = IRecord>(
args: CreateManyResolverArgs<Record>,
options: WorkspaceQueryBuilderOptions,
): Promise<string> {
return this.createManyQueryFactory.create<Record>(args, options);
}
updateOne<Record extends IRecord = IRecord>(
initialArgs: UpdateOneResolverArgs<Record>,
options: WorkspaceQueryBuilderOptions,
): Promise<string> {
return this.updateOneQueryFactory.create<Record>(initialArgs, options);
}
deleteOne(
args: DeleteOneResolverArgs,
options: WorkspaceQueryBuilderOptions,
): Promise<string> {
return this.deleteOneQueryFactory.create(args, options);
}
updateMany<
Record extends IRecord = IRecord,
Filter extends RecordFilter = RecordFilter,
>(
args: UpdateManyResolverArgs<Record, Filter>,
options: UpdateManyQueryFactoryOptions,
): Promise<string> {
return this.updateManyQueryFactory.create(args, options);
}
deleteMany<Filter extends RecordFilter = RecordFilter>(
args: DeleteManyResolverArgs<Filter>,
options: DeleteManyQueryFactoryOptions,
): Promise<string> {
return this.deleteManyQueryFactory.create(args, options);
}
}

View File

@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { ObjectMetadataModule } from 'src/engine-metadata/object-metadata/object-metadata.module';
import { FieldsStringFactory } from 'src/engine/graphql/workspace-query-builder/factories/fields-string.factory';
import { RecordPositionQueryFactory } from 'src/engine/graphql/workspace-query-builder/factories/record-position-query.factory';
import { WorkspaceQueryBuilderFactory } from './workspace-query-builder.factory';
import { workspaceQueryBuilderFactories } from './factories/factories';
@Module({
imports: [ObjectMetadataModule],
providers: [...workspaceQueryBuilderFactories, WorkspaceQueryBuilderFactory],
exports: [
WorkspaceQueryBuilderFactory,
FieldsStringFactory,
RecordPositionQueryFactory,
],
})
export class WorkspaceQueryBuilderModule {}

View File

@ -0,0 +1,63 @@
import { Test, TestingModule } from '@nestjs/testing';
import { WorkspaceQueryRunnerOptions } from 'src/engine/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import { QueryRunnerArgsFactory } from 'src/engine/graphql/workspace-query-runner/factories/query-runner-args.factory';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { RecordPositionFactory } from 'src/engine/graphql/workspace-query-runner/factories/record-position.factory';
describe('QueryRunnerArgsFactory', () => {
const recordPositionFactory = {
create: jest.fn().mockResolvedValue(2),
};
const options = {
fieldMetadataCollection: [
{ name: 'position', type: FieldMetadataType.POSITION },
] as FieldMetadataInterface[],
objectMetadataItem: { isCustom: true, nameSingular: 'test' },
} as WorkspaceQueryRunnerOptions;
let factory: QueryRunnerArgsFactory;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
QueryRunnerArgsFactory,
{
provide: RecordPositionFactory,
useValue: {
create: recordPositionFactory.create,
},
},
],
}).compile();
factory = module.get<QueryRunnerArgsFactory>(QueryRunnerArgsFactory);
});
it('should be defined', () => {
expect(factory).toBeDefined();
});
describe('create', () => {
it('should simply return the args when data is an empty array', async () => {
const args = {
data: [],
};
const result = await factory.create(args, options);
expect(result).toEqual(args);
});
it('should override args when of type array', async () => {
const args = { data: [{ id: 1 }, { position: 'last' }] };
const result = await factory.create(args, options);
expect(result).toEqual({
data: [{ id: 1 }, { position: 2 }],
});
});
});
});

View File

@ -0,0 +1,64 @@
import { TestingModule, Test } from '@nestjs/testing';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { RecordPositionQueryFactory } from 'src/engine/graphql/workspace-query-builder/factories/record-position-query.factory';
import { RecordPositionFactory } from 'src/engine/graphql/workspace-query-runner/factories/record-position.factory';
describe('RecordPositionFactory', () => {
const recordPositionQueryFactory = {
create: jest.fn().mockResolvedValue('query'),
};
const workspaceDataSourceService = {
getSchemaName: jest.fn().mockReturnValue('schemaName'),
executeRawQuery: jest.fn().mockResolvedValue([{ position: 1 }]),
};
let factory: RecordPositionFactory;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RecordPositionFactory,
{
provide: RecordPositionQueryFactory,
useValue: recordPositionQueryFactory,
},
{
provide: WorkspaceDataSourceService,
useValue: workspaceDataSourceService,
},
],
}).compile();
factory = module.get<RecordPositionFactory>(RecordPositionFactory);
});
it('should be defined', () => {
expect(factory).toBeDefined();
});
describe('create', () => {
const objectMetadata = { isCustom: false, nameSingular: 'company' };
const workspaceId = 'workspaceId';
it('should return the value when value is a number', async () => {
const value = 1;
const result = await factory.create(value, objectMetadata, workspaceId);
expect(result).toEqual(value);
});
it('should return the existing position / 2 when value is first', async () => {
const value = 'first';
const result = await factory.create(value, objectMetadata, workspaceId);
expect(result).toEqual(0.5);
});
it('should return the existing position + 1 when value is last', async () => {
const value = 'last';
const result = await factory.create(value, objectMetadata, workspaceId);
expect(result).toEqual(2);
});
});
});

View File

@ -0,0 +1,7 @@
import { RecordPositionFactory } from './record-position.factory';
import { QueryRunnerArgsFactory } from './query-runner-args.factory';
export const workspaceQueryRunnerFactories = [
QueryRunnerArgsFactory,
RecordPositionFactory,
];

View File

@ -0,0 +1,72 @@
import { Injectable } from '@nestjs/common';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import { WorkspaceQueryRunnerOptions } from 'src/engine/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { RecordPositionFactory } from './record-position.factory';
@Injectable()
export class QueryRunnerArgsFactory {
constructor(private readonly recordPositionFactory: RecordPositionFactory) {}
async create(
args: Record<string, any>,
options: WorkspaceQueryRunnerOptions,
) {
const fieldMetadataCollection = options.fieldMetadataCollection;
const fieldMetadataMap = new Map(
fieldMetadataCollection.map((fieldMetadata) => [
fieldMetadata.name,
fieldMetadata,
]),
);
return {
data: await Promise.all(
args.data.map((arg) =>
this.overrideArgByFieldMetadata(arg, options, fieldMetadataMap),
),
),
};
}
private async overrideArgByFieldMetadata(
arg: Record<string, any>,
options: WorkspaceQueryRunnerOptions,
fieldMetadataMap: Map<string, FieldMetadataInterface>,
) {
const createArgPromiseByArgKey = Object.entries(arg).map(
async ([key, value]) => {
const fieldMetadata = fieldMetadataMap.get(key);
if (!fieldMetadata) {
return [key, await Promise.resolve(value)];
}
switch (fieldMetadata.type) {
case FieldMetadataType.POSITION:
return [
key,
await this.recordPositionFactory.create(
value,
{
isCustom: options.objectMetadataItem.isCustom,
nameSingular: options.objectMetadataItem.nameSingular,
},
options.workspaceId,
),
];
default:
return [key, await Promise.resolve(value)];
}
},
);
const newArgEntries = await Promise.all(createArgPromiseByArgKey);
return Object.fromEntries(newArgEntries);
}
}

View File

@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import {
RecordPositionQueryFactory,
RecordPositionQueryType,
} from 'src/engine/graphql/workspace-query-builder/factories/record-position-query.factory';
@Injectable()
export class RecordPositionFactory {
constructor(
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
private readonly recordPositionQueryFactory: RecordPositionQueryFactory,
) {}
async create(
value: number | 'first' | 'last',
objectMetadata: { isCustom: boolean; nameSingular: string },
workspaceId: string,
): Promise<number> {
if (typeof value === 'number') {
return value;
}
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
const query = await this.recordPositionQueryFactory.create(
RecordPositionQueryType.GET,
value,
objectMetadata,
dataSourceSchema,
);
const records = await this.workspaceDataSourceService.executeRawQuery(
query,
[],
workspaceId,
undefined,
);
return (
(value === 'first'
? records[0]?.position / 2
: records[0]?.position + 1) || 1
);
}
}

View File

@ -0,0 +1,15 @@
import { Record as IRecord } from 'src/engine/graphql/workspace-query-builder/interfaces/record.interface';
export interface PGGraphQLResponse<Data = any> {
resolve: {
data: Data;
errors: any[];
};
}
export type PGGraphQLResult<Data = any> = [PGGraphQLResponse<Data>];
export interface PGGraphQLMutation<Record = IRecord> {
affectedRows: number;
records: Record[];
}

View File

@ -0,0 +1,13 @@
import { GraphQLResolveInfo } from 'graphql';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
export interface WorkspaceQueryRunnerOptions {
workspaceId: string;
userId: string | undefined;
info: GraphQLResolveInfo;
objectMetadataItem: ObjectMetadataInterface;
fieldMetadataCollection: FieldMetadataInterface[];
objectMetadataCollection: ObjectMetadataInterface[];
}

View File

@ -0,0 +1,93 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { MessageQueueJob } from 'src/integrations/message-queue/interfaces/message-queue-job.interface';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { ObjectMetadataService } from 'src/engine-metadata/object-metadata/object-metadata.service';
import { DataSourceService } from 'src/engine-metadata/data-source/data-source.service';
import { MessageQueueService } from 'src/integrations/message-queue/services/message-queue.service';
import { MessageQueue } from 'src/integrations/message-queue/message-queue.constants';
import {
CallWebhookJob,
CallWebhookJobData,
} from 'src/engine/graphql/workspace-query-runner/jobs/call-webhook.job';
export enum CallWebhookJobsJobOperation {
create = 'create',
update = 'update',
delete = 'delete',
}
export type CallWebhookJobsJobData = {
workspaceId: string;
objectMetadataItem: ObjectMetadataInterface;
record: any;
operation: CallWebhookJobsJobOperation;
};
@Injectable()
export class CallWebhookJobsJob
implements MessageQueueJob<CallWebhookJobsJobData>
{
private readonly logger = new Logger(CallWebhookJobsJob.name);
constructor(
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
private readonly objectMetadataService: ObjectMetadataService,
private readonly dataSourceService: DataSourceService,
@Inject(MessageQueue.webhookQueue)
private readonly messageQueueService: MessageQueueService,
) {}
async handle(data: CallWebhookJobsJobData): Promise<void> {
const dataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
data.workspaceId,
);
const workspaceDataSource =
await this.workspaceDataSourceService.connectToWorkspaceDataSource(
data.workspaceId,
);
const nameSingular = data.objectMetadataItem.nameSingular;
const operation = data.operation;
const eventType = `${operation}.${nameSingular}`;
const webhooks: { id: string; targetUrl: string }[] =
await workspaceDataSource?.query(
`
SELECT * FROM ${dataSourceMetadata.schema}."webhook"
WHERE operation LIKE '%${eventType}%'
OR operation LIKE '%*.${nameSingular}%'
OR operation LIKE '%${operation}.*%'
OR operation LIKE '%*.*%'
`,
);
webhooks.forEach((webhook) => {
this.messageQueueService.add<CallWebhookJobData>(
CallWebhookJob.name,
{
targetUrl: webhook.targetUrl,
eventType,
objectMetadata: {
id: data.objectMetadataItem.id,
nameSingular: data.objectMetadataItem.nameSingular,
},
workspaceId: data.workspaceId,
webhookId: webhook.id,
eventDate: new Date(),
record: data.record,
},
{ retryLimit: 3 },
);
});
if (webhooks.length) {
this.logger.log(
`CallWebhookJobsJob on eventType '${eventType}' called on webhooks ids [\n"${webhooks
.map((webhook) => webhook.id)
.join('",\n"')}"\n]`,
);
}
}
}

View File

@ -0,0 +1,34 @@
import { Injectable, Logger } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { MessageQueueJob } from 'src/integrations/message-queue/interfaces/message-queue-job.interface';
export type CallWebhookJobData = {
targetUrl: string;
eventType: string;
objectMetadata: { id: string; nameSingular: string };
workspaceId: string;
webhookId: string;
eventDate: Date;
record: any;
};
@Injectable()
export class CallWebhookJob implements MessageQueueJob<CallWebhookJobData> {
private readonly logger = new Logger(CallWebhookJob.name);
constructor(private readonly httpService: HttpService) {}
async handle(data: CallWebhookJobData): Promise<void> {
try {
await this.httpService.axiosRef.post(data.targetUrl, data);
this.logger.log(
`CallWebhookJob successfully called on targetUrl '${data.targetUrl}'`,
);
} catch (err) {
this.logger.error(
`Error calling webhook on targetUrl '${data.targetUrl}': ${err}`,
);
}
}
}

View File

@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { MessageQueueJob } from 'src/integrations/message-queue/interfaces/message-queue-job.interface';
import { RecordPositionBackfillService } from 'src/engine/graphql/workspace-query-runner/services/record-position-backfill-service';
export type RecordPositionBackfillJobData = {
workspaceId: string;
objectMetadata: { nameSingular: string; isCustom: boolean };
recordId: string;
};
@Injectable()
export class RecordPositionBackfillJob
implements MessageQueueJob<RecordPositionBackfillJobData>
{
constructor(
private readonly recordPositionBackfillService: RecordPositionBackfillService,
) {}
async handle(data: RecordPositionBackfillJobData): Promise<void> {
this.recordPositionBackfillService.backfill(
data.workspaceId,
data.objectMetadata,
data.recordId,
);
}
}

View File

@ -0,0 +1,56 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import {
CreatedObjectMetadata,
ObjectRecordCreateEvent,
} from 'src/integrations/event-emitter/types/object-record-create.event';
import { MessageQueue } from 'src/integrations/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/integrations/message-queue/services/message-queue.service';
import {
RecordPositionBackfillJob,
RecordPositionBackfillJobData,
} from 'src/engine/graphql/workspace-query-runner/jobs/record-position-backfill.job';
@Injectable()
export class RecordPositionListener {
constructor(
@Inject(MessageQueue.recordPositionBackfillQueue)
private readonly messageQueueService: MessageQueueService,
) {}
@OnEvent('*.created')
async handleAllCreate(payload: ObjectRecordCreateEvent<any>) {
if (!hasPositionField(payload.createdObjectMetadata)) {
return;
}
if (hasPositionSet(payload.createdRecord)) {
return;
}
this.messageQueueService.add<RecordPositionBackfillJobData>(
RecordPositionBackfillJob.name,
{
workspaceId: payload.workspaceId,
recordId: payload.createdRecord.id,
objectMetadata: payload.createdObjectMetadata,
},
);
}
}
const hasPositionField = (
createdObjectMetadata: CreatedObjectMetadata,
): boolean => {
return (
createdObjectMetadata.isCustom ||
['opportunity', 'company', 'people'].includes(
createdObjectMetadata.nameSingular,
)
);
};
const hasPositionSet = (createdRecord: any): boolean => {
return !!createdRecord?.position;
};

View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { RecordPositionQueryFactory } from 'src/engine/graphql/workspace-query-builder/factories/record-position-query.factory';
import { RecordPositionFactory } from 'src/engine/graphql/workspace-query-runner/factories/record-position.factory';
import { RecordPositionBackfillService } from 'src/engine/graphql/workspace-query-runner/services/record-position-backfill-service';
@Module({
imports: [WorkspaceDataSourceModule],
providers: [
RecordPositionFactory,
RecordPositionQueryFactory,
RecordPositionBackfillService,
],
exports: [RecordPositionBackfillService],
})
export class RecordPositionBackfillModule {}

View File

@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import {
RecordPositionQueryFactory,
RecordPositionQueryType,
} from 'src/engine/graphql/workspace-query-builder/factories/record-position-query.factory';
import { RecordPositionFactory } from 'src/engine/graphql/workspace-query-runner/factories/record-position.factory';
@Injectable()
export class RecordPositionBackfillService {
constructor(
private readonly recordPositionFactory: RecordPositionFactory,
private readonly recordPositionQueryFactory: RecordPositionQueryFactory,
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
) {}
async backfill(
workspaceId: string,
objectMetadata: { nameSingular: string; isCustom: boolean },
recordId: string,
) {
const position = await this.recordPositionFactory.create(
'last',
objectMetadata as ObjectMetadataInterface,
workspaceId,
);
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
const query = await this.recordPositionQueryFactory.create(
RecordPositionQueryType.UPDATE,
position,
objectMetadata as ObjectMetadataInterface,
dataSourceSchema,
);
this.workspaceDataSourceService.executeRawQuery(
query,
[position, recordId],
workspaceId,
undefined,
);
}
}

View File

@ -0,0 +1,105 @@
import {
isSpecialKey,
handleSpecialKey,
parseResult,
} from 'src/engine/graphql/workspace-query-runner/utils/parse-result.util';
describe('isSpecialKey', () => {
test('should return true if the key starts with "___"', () => {
expect(isSpecialKey('___specialKey')).toBe(true);
});
test('should return false if the key does not start with "___"', () => {
expect(isSpecialKey('normalKey')).toBe(false);
});
});
describe('handleSpecialKey', () => {
let result;
beforeEach(() => {
result = {};
});
test('should correctly process a special key and add it to the result object', () => {
handleSpecialKey(result, '___complexField_link', 'value1');
expect(result).toEqual({
complexField: {
link: 'value1',
},
});
});
test('should add values under the same newKey if called multiple times', () => {
handleSpecialKey(result, '___complexField_link', 'value1');
handleSpecialKey(result, '___complexField_text', 'value2');
expect(result).toEqual({
complexField: {
link: 'value1',
text: 'value2',
},
});
});
test('should not create a new field if the special key is not correctly formed', () => {
handleSpecialKey(result, '___complexField', 'value1');
expect(result).toEqual({});
});
});
describe('parseResult', () => {
test('should recursively parse an object and handle special keys', () => {
const obj = {
normalField: 'value1',
___specialField_part1: 'value2',
nested: {
___specialFieldNested_part2: 'value3',
},
};
const expectedResult = {
normalField: 'value1',
specialField: {
part1: 'value2',
},
nested: {
specialFieldNested: {
part2: 'value3',
},
},
};
expect(parseResult(obj)).toEqual(expectedResult);
});
test('should handle arrays and parse each element', () => {
const objArray = [
{
___specialField_part1: 'value1',
},
{
___specialField_part2: 'value2',
},
];
const expectedResult = [
{
specialField: {
part1: 'value1',
},
},
{
specialField: {
part2: 'value2',
},
},
];
expect(parseResult(objArray)).toEqual(expectedResult);
});
test('should return the original value if it is not an object or array', () => {
expect(parseResult('stringValue')).toBe('stringValue');
expect(parseResult(12345)).toBe(12345);
});
});

View File

@ -0,0 +1,34 @@
import {
BadRequestException,
HttpException,
InternalServerErrorException,
} from '@nestjs/common';
interface PgGraphQLErrorMapping {
[key: string]: (command: string, objectName: string) => HttpException;
}
const pgGraphQLErrorMapping: PgGraphQLErrorMapping = {
'delete impacts too many records': (command, objectName) =>
new BadRequestException(
`Cannot ${command} ${objectName} because it impacts too many records.`,
),
};
export const computePgGraphQLError = (
command: string,
objectName: string,
errors: any[],
) => {
const error = errors[0];
const errorMessage = error?.message;
const mappedError = pgGraphQLErrorMapping[errorMessage];
if (mappedError) {
return mappedError(command, objectName);
}
return new InternalServerErrorException(
`GraphQL errors on ${command}${objectName}: ${JSON.stringify(error)}`,
);
};

View File

@ -0,0 +1,53 @@
export const isSpecialKey = (key: string): boolean => {
return key.startsWith('___');
};
export const handleSpecialKey = (
result: any,
key: string,
value: any,
): void => {
const parts = key.split('_').filter((part) => part);
// If parts don't contain enough information, return without altering result
if (parts.length < 2) {
return;
}
const newKey = parts.slice(0, -1).join('');
const subKey = parts[parts.length - 1];
if (!result[newKey]) {
result[newKey] = {};
}
result[newKey][subKey] = value;
};
export const parseResult = (obj: any): any => {
if (obj === null || typeof obj !== 'object' || typeof obj === 'function') {
return obj;
}
if (Array.isArray(obj)) {
return obj.map((item) => parseResult(item));
}
const result: any = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
if (typeof obj[key] === 'object' && obj[key] !== null) {
result[key] = parseResult(obj[key]);
} else if (key === '__typename') {
result[key] = obj[key].replace(/^_*/, '');
} else if (isSpecialKey(key)) {
handleSpecialKey(result, key, obj[key]);
} else {
result[key] = obj[key];
}
}
}
return result;
};

View File

@ -0,0 +1,9 @@
import { ResolverArgs } from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
export interface WorkspacePreQueryHook {
execute(
userId: string | undefined,
workspaceId: string,
payload: ResolverArgs,
): Promise<void>;
}

View File

@ -0,0 +1,52 @@
import {
CreateManyResolverArgs,
CreateOneResolverArgs,
DeleteManyResolverArgs,
DeleteOneResolverArgs,
FindDuplicatesResolverArgs,
FindManyResolverArgs,
FindOneResolverArgs,
UpdateManyResolverArgs,
UpdateOneResolverArgs,
} from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
export type ExecutePreHookMethod =
| 'createMany'
| 'createOne'
| 'deleteMany'
| 'deleteOne'
| 'findMany'
| 'findOne'
| 'findDuplicates'
| 'updateMany'
| 'updateOne';
export type ObjectName = string;
export type HookName = string;
export type WorkspaceQueryHook = {
[key in ObjectName]: {
[key in ExecutePreHookMethod]?: HookName[];
};
};
export type WorkspacePreQueryHookPayload<T> = T extends 'createMany'
? CreateManyResolverArgs
: T extends 'createOne'
? CreateOneResolverArgs
: T extends 'deleteMany'
? DeleteManyResolverArgs
: T extends 'deleteOne'
? DeleteOneResolverArgs
: T extends 'findMany'
? FindManyResolverArgs
: T extends 'findOne'
? FindOneResolverArgs
: T extends 'updateMany'
? UpdateManyResolverArgs
: T extends 'updateOne'
? UpdateOneResolverArgs
: T extends 'findDuplicates'
? FindDuplicatesResolverArgs
: never;

View File

@ -0,0 +1,11 @@
import { MessageFindManyPreQueryHook } from 'src/business/modules/message/query-hooks/message/message-find-many.pre-query.hook';
import { MessageFindOnePreQueryHook } from 'src/business/modules/message/query-hooks/message/message-find-one.pre-query-hook';
import { WorkspaceQueryHook } from 'src/engine/graphql/workspace-query-runner/workspace-pre-query-hook/types/workspace-query-hook.type';
// TODO: move to a decorator
export const workspacePreQueryHooks: WorkspaceQueryHook = {
message: {
findOne: [MessageFindOnePreQueryHook.name],
findMany: [MessageFindManyPreQueryHook.name],
},
};

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { MessagingQueryHookModule } from 'src/business/modules/message/query-hooks/messaging-query-hook.module';
import { WorkspacePreQueryHookService } from 'src/engine/graphql/workspace-query-runner/workspace-pre-query-hook/workspace-pre-query-hook.service';
@Module({
imports: [MessagingQueryHookModule],
providers: [WorkspacePreQueryHookService],
exports: [WorkspacePreQueryHookService],
})
export class WorkspacePreQueryHookModule {}

View File

@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { WorkspacePreQueryHook } from 'src/engine/graphql/workspace-query-runner/workspace-pre-query-hook/interfaces/workspace-pre-query-hook.interface';
import {
ExecutePreHookMethod,
WorkspacePreQueryHookPayload,
} from 'src/engine/graphql/workspace-query-runner/workspace-pre-query-hook/types/workspace-query-hook.type';
import { workspacePreQueryHooks } from 'src/engine/graphql/workspace-query-runner/workspace-pre-query-hook/workspace-pre-query-hook.config';
@Injectable()
export class WorkspacePreQueryHookService {
constructor(private readonly workspaceQueryHookModuleRef: ModuleRef) {}
public async executePreHooks<T extends ExecutePreHookMethod>(
userId: string | undefined,
workspaceId: string,
objectName: string,
method: T,
payload: WorkspacePreQueryHookPayload<T>,
): Promise<void> {
const hooks = workspacePreQueryHooks[objectName] || [];
for (const hookName of Object.values(hooks[method] ?? [])) {
const hook: WorkspacePreQueryHook =
await this.workspaceQueryHookModuleRef.get(hookName, {
strict: false,
});
await hook.execute(userId, workspaceId, payload);
}
}
}

View File

@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { WorkspaceQueryBuilderModule } from 'src/engine/graphql/workspace-query-builder/workspace-query-builder.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { WorkspacePreQueryHookModule } from 'src/engine/graphql/workspace-query-runner/workspace-pre-query-hook/workspace-pre-query-hook.module';
import { workspaceQueryRunnerFactories } from 'src/engine/graphql/workspace-query-runner/factories';
import { RecordPositionListener } from 'src/engine/graphql/workspace-query-runner/listeners/record-position.listener';
import { WorkspaceQueryRunnerService } from './workspace-query-runner.service';
@Module({
imports: [
WorkspaceQueryBuilderModule,
WorkspaceDataSourceModule,
WorkspacePreQueryHookModule,
],
providers: [
WorkspaceQueryRunnerService,
...workspaceQueryRunnerFactories,
RecordPositionListener,
],
exports: [WorkspaceQueryRunnerService],
})
export class WorkspaceQueryRunnerModule {}

View File

@ -0,0 +1,519 @@
import {
BadRequestException,
Inject,
Injectable,
Logger,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import isEmpty from 'lodash.isempty';
import { IConnection } from 'src/utils/pagination/interfaces/connection.interface';
import {
Record as IRecord,
RecordFilter,
RecordOrderBy,
} from 'src/engine/graphql/workspace-query-builder/interfaces/record.interface';
import {
CreateManyResolverArgs,
CreateOneResolverArgs,
DeleteManyResolverArgs,
DeleteOneResolverArgs,
FindDuplicatesResolverArgs,
FindManyResolverArgs,
FindOneResolverArgs,
UpdateManyResolverArgs,
UpdateOneResolverArgs,
} from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { WorkspaceQueryBuilderFactory } from 'src/engine/graphql/workspace-query-builder/workspace-query-builder.factory';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { MessageQueueService } from 'src/integrations/message-queue/services/message-queue.service';
import { MessageQueue } from 'src/integrations/message-queue/message-queue.constants';
import {
CallWebhookJobsJob,
CallWebhookJobsJobData,
CallWebhookJobsJobOperation,
} from 'src/engine/graphql/workspace-query-runner/jobs/call-webhook-jobs.job';
import { parseResult } from 'src/engine/graphql/workspace-query-runner/utils/parse-result.util';
import { computeObjectTargetTable } from 'src/engine-workspace/utils/compute-object-target-table.util';
import { ObjectRecordDeleteEvent } from 'src/integrations/event-emitter/types/object-record-delete.event';
import { ObjectRecordCreateEvent } from 'src/integrations/event-emitter/types/object-record-create.event';
import { ObjectRecordUpdateEvent } from 'src/integrations/event-emitter/types/object-record-update.event';
import { WorkspacePreQueryHookService } from 'src/engine/graphql/workspace-query-runner/workspace-pre-query-hook/workspace-pre-query-hook.service';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { NotFoundError } from 'src/engine/filters/utils/graphql-errors.util';
import { QueryRunnerArgsFactory } from 'src/engine/graphql/workspace-query-runner/factories/query-runner-args.factory';
import { WorkspaceQueryRunnerOptions } from './interfaces/query-runner-option.interface';
import {
PGGraphQLMutation,
PGGraphQLResult,
} from './interfaces/pg-graphql.interface';
import { computePgGraphQLError } from './utils/compute-pg-graphql-error.util';
@Injectable()
export class WorkspaceQueryRunnerService {
private readonly logger = new Logger(WorkspaceQueryRunnerService.name);
constructor(
private readonly workspaceQueryBuilderFactory: WorkspaceQueryBuilderFactory,
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
private readonly queryRunnerArgsFactory: QueryRunnerArgsFactory,
@Inject(MessageQueue.webhookQueue)
private readonly messageQueueService: MessageQueueService,
private readonly eventEmitter: EventEmitter2,
private readonly workspacePreQueryHookService: WorkspacePreQueryHookService,
private readonly environmentService: EnvironmentService,
) {}
async findMany<
Record extends IRecord = IRecord,
Filter extends RecordFilter = RecordFilter,
OrderBy extends RecordOrderBy = RecordOrderBy,
>(
args: FindManyResolverArgs<Filter, OrderBy>,
options: WorkspaceQueryRunnerOptions,
): Promise<IConnection<Record> | undefined> {
const { workspaceId, userId, objectMetadataItem } = options;
const start = performance.now();
const query = await this.workspaceQueryBuilderFactory.findMany(
args,
options,
);
await this.workspacePreQueryHookService.executePreHooks(
userId,
workspaceId,
objectMetadataItem.nameSingular,
'findMany',
args,
);
const result = await this.execute(query, workspaceId);
const end = performance.now();
this.logger.log(
`query time: ${end - start} ms on query ${
options.objectMetadataItem.nameSingular
}`,
);
return this.parseResult<IConnection<Record>>(
result,
objectMetadataItem,
'',
);
}
async findOne<
Record extends IRecord = IRecord,
Filter extends RecordFilter = RecordFilter,
>(
args: FindOneResolverArgs<Filter>,
options: WorkspaceQueryRunnerOptions,
): Promise<Record | undefined> {
if (!args.filter || Object.keys(args.filter).length === 0) {
throw new BadRequestException('Missing filter argument');
}
const { workspaceId, userId, objectMetadataItem } = options;
const query = await this.workspaceQueryBuilderFactory.findOne(
args,
options,
);
await this.workspacePreQueryHookService.executePreHooks(
userId,
workspaceId,
objectMetadataItem.nameSingular,
'findOne',
args,
);
const result = await this.execute(query, workspaceId);
const parsedResult = this.parseResult<IConnection<Record>>(
result,
objectMetadataItem,
'',
);
return parsedResult?.edges?.[0]?.node;
}
async findDuplicates<TRecord extends IRecord = IRecord>(
args: FindDuplicatesResolverArgs<TRecord>,
options: WorkspaceQueryRunnerOptions,
): Promise<IConnection<TRecord> | undefined> {
if (!args.data && !args.id) {
throw new BadRequestException(
'You have to provide either "data" or "id" argument',
);
}
if (!args.id && isEmpty(args.data)) {
throw new BadRequestException(
'The "data" condition can not be empty when ID input not provided',
);
}
const { workspaceId, userId, objectMetadataItem } = options;
let existingRecord: Record<string, unknown> | undefined;
if (args.id) {
const existingRecordQuery =
this.workspaceQueryBuilderFactory.findDuplicatesExistingRecord(
args.id,
options,
);
const existingRecordResult = await this.execute(
existingRecordQuery,
workspaceId,
);
const parsedResult = this.parseResult<Record<string, unknown>>(
existingRecordResult,
objectMetadataItem,
'',
);
existingRecord = parsedResult?.edges?.[0]?.node;
if (!existingRecord) {
throw new NotFoundError(`Object with id ${args.id} not found`);
}
}
const query = await this.workspaceQueryBuilderFactory.findDuplicates(
args,
options,
existingRecord,
);
await this.workspacePreQueryHookService.executePreHooks(
userId,
workspaceId,
objectMetadataItem.nameSingular,
'findDuplicates',
args,
);
const result = await this.execute(query, workspaceId);
return this.parseResult<IConnection<TRecord>>(
result,
objectMetadataItem,
'',
);
}
async createMany<Record extends IRecord = IRecord>(
args: CreateManyResolverArgs<Record>,
options: WorkspaceQueryRunnerOptions,
): Promise<Record[] | undefined> {
const { workspaceId, objectMetadataItem } = options;
const computedArgs = await this.queryRunnerArgsFactory.create(
args,
options,
);
const query = await this.workspaceQueryBuilderFactory.createMany(
computedArgs,
options,
);
const result = await this.execute(query, workspaceId);
const parsedResults = this.parseResult<PGGraphQLMutation<Record>>(
result,
objectMetadataItem,
'insertInto',
)?.records;
await this.triggerWebhooks<Record>(
parsedResults,
CallWebhookJobsJobOperation.create,
options,
);
parsedResults.forEach((record) => {
this.eventEmitter.emit(`${objectMetadataItem.nameSingular}.created`, {
workspaceId,
createdRecord: this.removeNestedProperties(record),
createdObjectMetadata: {
nameSingular: objectMetadataItem.nameSingular,
isCustom: objectMetadataItem.isCustom,
},
} satisfies ObjectRecordCreateEvent<any>);
});
return parsedResults;
}
async createOne<Record extends IRecord = IRecord>(
args: CreateOneResolverArgs<Record>,
options: WorkspaceQueryRunnerOptions,
): Promise<Record | undefined> {
const results = await this.createMany({ data: [args.data] }, options);
return results?.[0];
}
async updateOne<Record extends IRecord = IRecord>(
args: UpdateOneResolverArgs<Record>,
options: WorkspaceQueryRunnerOptions,
): Promise<Record | undefined> {
const { workspaceId, objectMetadataItem } = options;
const existingRecord = await this.findOne(
{ filter: { id: { eq: args.id } } } as FindOneResolverArgs,
options,
);
const query = await this.workspaceQueryBuilderFactory.updateOne(
args,
options,
);
const result = await this.execute(query, workspaceId);
const parsedResults = this.parseResult<PGGraphQLMutation<Record>>(
result,
objectMetadataItem,
'update',
)?.records;
await this.triggerWebhooks<Record>(
parsedResults,
CallWebhookJobsJobOperation.update,
options,
);
this.eventEmitter.emit(`${objectMetadataItem.nameSingular}.updated`, {
workspaceId,
previousRecord: this.removeNestedProperties(existingRecord as Record),
updatedRecord: this.removeNestedProperties(parsedResults?.[0]),
} satisfies ObjectRecordUpdateEvent<any>);
return parsedResults?.[0];
}
async updateMany<Record extends IRecord = IRecord>(
args: UpdateManyResolverArgs<Record>,
options: WorkspaceQueryRunnerOptions,
): Promise<Record[] | undefined> {
const { workspaceId, objectMetadataItem } = options;
const maximumRecordAffected = this.environmentService.get(
'MUTATION_MAXIMUM_RECORD_AFFECTED',
);
const query = await this.workspaceQueryBuilderFactory.updateMany(args, {
...options,
atMost: maximumRecordAffected,
});
const result = await this.execute(query, workspaceId);
const parsedResults = this.parseResult<PGGraphQLMutation<Record>>(
result,
objectMetadataItem,
'update',
)?.records;
await this.triggerWebhooks<Record>(
parsedResults,
CallWebhookJobsJobOperation.update,
options,
);
return parsedResults;
}
async deleteMany<
Record extends IRecord = IRecord,
Filter extends RecordFilter = RecordFilter,
>(
args: DeleteManyResolverArgs<Filter>,
options: WorkspaceQueryRunnerOptions,
): Promise<Record[] | undefined> {
const { workspaceId, objectMetadataItem } = options;
const maximumRecordAffected = this.environmentService.get(
'MUTATION_MAXIMUM_RECORD_AFFECTED',
);
const query = await this.workspaceQueryBuilderFactory.deleteMany(args, {
...options,
atMost: maximumRecordAffected,
});
const result = await this.execute(query, workspaceId);
const parsedResults = this.parseResult<PGGraphQLMutation<Record>>(
result,
objectMetadataItem,
'deleteFrom',
)?.records;
await this.triggerWebhooks<Record>(
parsedResults,
CallWebhookJobsJobOperation.delete,
options,
);
parsedResults.forEach((record) => {
this.eventEmitter.emit(`${objectMetadataItem.nameSingular}.deleted`, {
workspaceId,
deletedRecord: [this.removeNestedProperties(record)],
} satisfies ObjectRecordDeleteEvent<any>);
});
return parsedResults;
}
async deleteOne<Record extends IRecord = IRecord>(
args: DeleteOneResolverArgs,
options: WorkspaceQueryRunnerOptions,
): Promise<Record | undefined> {
const { workspaceId, objectMetadataItem } = options;
const query = await this.workspaceQueryBuilderFactory.deleteOne(
args,
options,
);
const result = await this.execute(query, workspaceId);
const parsedResults = this.parseResult<PGGraphQLMutation<Record>>(
result,
objectMetadataItem,
'deleteFrom',
)?.records;
await this.triggerWebhooks<Record>(
parsedResults,
CallWebhookJobsJobOperation.delete,
options,
);
this.eventEmitter.emit(`${objectMetadataItem.nameSingular}.deleted`, {
workspaceId,
deletedRecord: this.removeNestedProperties(parsedResults?.[0]),
} satisfies ObjectRecordDeleteEvent<any>);
return parsedResults?.[0];
}
private removeNestedProperties<Record extends IRecord = IRecord>(
record: Record,
) {
if (!record) {
return;
}
const sanitizedRecord = {};
for (const [key, value] of Object.entries(record)) {
if (value && typeof value === 'object' && value['edges']) {
continue;
}
sanitizedRecord[key] = value;
}
return sanitizedRecord;
}
async execute(
query: string,
workspaceId: string,
): Promise<PGGraphQLResult | undefined> {
const workspaceDataSource =
await this.workspaceDataSourceService.connectToWorkspaceDataSource(
workspaceId,
);
await workspaceDataSource?.query(`
SET search_path TO ${this.workspaceDataSourceService.getSchemaName(
workspaceId,
)};
`);
const results = await workspaceDataSource?.query<PGGraphQLResult>(`
SELECT graphql.resolve($$
${query}
$$);
`);
return results;
}
private parseResult<Result>(
graphqlResult: PGGraphQLResult | undefined,
objectMetadataItem: ObjectMetadataInterface,
command: string,
): Result {
const entityKey = `${command}${computeObjectTargetTable(
objectMetadataItem,
)}Collection`;
const result = graphqlResult?.[0]?.resolve?.data?.[entityKey];
const errors = graphqlResult?.[0]?.resolve?.errors;
if (!result) {
this.logger.log(
`No result found for ${entityKey}, graphqlResult: ` +
JSON.stringify(graphqlResult, null, 3),
);
}
if (
result &&
['update', 'deleteFrom'].includes(command) &&
!result.affectedCount
) {
throw new BadRequestException('No rows were affected.');
}
if (errors && errors.length > 0) {
const error = computePgGraphQLError(
command,
objectMetadataItem.nameSingular,
errors,
);
throw error;
}
return parseResult(result);
}
async executeAndParse<Result>(
query: string,
objectMetadataItem: ObjectMetadataInterface,
command: string,
workspaceId: string,
): Promise<Result> {
const result = await this.execute(query, workspaceId);
return this.parseResult(result, objectMetadataItem, command);
}
async triggerWebhooks<Record>(
jobsData: Record[] | undefined,
operation: CallWebhookJobsJobOperation,
options: WorkspaceQueryRunnerOptions,
) {
if (!Array.isArray(jobsData)) {
return;
}
jobsData.forEach((jobData) => {
this.messageQueueService.add<CallWebhookJobsJobData>(
CallWebhookJobsJob.name,
{
record: jobData,
workspaceId: options.workspaceId,
operation,
objectMetadataItem: options.objectMetadataItem,
},
{ retryLimit: 3 },
);
});
}
}

View File

@ -0,0 +1,30 @@
import { RecordDuplicateCriteria } from 'src/engine/graphql/workspace-query-builder/interfaces/record.interface';
/**
* objectName: directly reference the name of the object from the metadata tables.
* columnNames: reference the column names not the field names.
* So if we need to reference a custom field, we should directly add the column name like `_customColumn`.
* If we need to terence a composite field, we should add all children of the composite like `nameFirstName` and `nameLastName`
*/
export const duplicateCriteriaCollection: RecordDuplicateCriteria[] = [
{
objectName: 'company',
columnNames: ['domainName'],
},
{
objectName: 'company',
columnNames: ['name'],
},
{
objectName: 'person',
columnNames: ['nameFirstName', 'nameLastName'],
},
{
objectName: 'person',
columnNames: ['linkedinLinkUrl'],
},
{
objectName: 'person',
columnNames: ['email'],
},
];

View File

@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import {
CreateManyResolverArgs,
Resolver,
} from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceSchemaBuilderContext } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import { WorkspaceQueryRunnerService } from 'src/engine/graphql/workspace-query-runner/workspace-query-runner.service';
@Injectable()
export class CreateManyResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'createMany' as const;
constructor(
private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService,
) {}
create(
context: WorkspaceSchemaBuilderContext,
): Resolver<CreateManyResolverArgs> {
const internalContext = context;
return (_source, args, context, info) => {
return this.workspaceQueryRunnerService.createMany(args, {
objectMetadataItem: internalContext.objectMetadataItem,
workspaceId: internalContext.workspaceId,
userId: internalContext.userId,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
});
};
}
}

View File

@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import {
CreateOneResolverArgs,
Resolver,
} from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceSchemaBuilderContext } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import { WorkspaceQueryRunnerService } from 'src/engine/graphql/workspace-query-runner/workspace-query-runner.service';
@Injectable()
export class CreateOneResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'createOne' as const;
constructor(
private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService,
) {}
create(
context: WorkspaceSchemaBuilderContext,
): Resolver<CreateOneResolverArgs> {
const internalContext = context;
return (_source, args, context, info) => {
return this.workspaceQueryRunnerService.createOne(args, {
objectMetadataItem: internalContext.objectMetadataItem,
workspaceId: internalContext.workspaceId,
userId: internalContext.userId,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
});
};
}
}

View File

@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import {
DeleteManyResolverArgs,
Resolver,
} from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceSchemaBuilderContext } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import { WorkspaceQueryRunnerService } from 'src/engine/graphql/workspace-query-runner/workspace-query-runner.service';
@Injectable()
export class DeleteManyResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'deleteMany' as const;
constructor(
private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService,
) {}
create(
context: WorkspaceSchemaBuilderContext,
): Resolver<DeleteManyResolverArgs> {
const internalContext = context;
return (_source, args, context, info) => {
return this.workspaceQueryRunnerService.deleteMany(args, {
objectMetadataItem: internalContext.objectMetadataItem,
workspaceId: internalContext.workspaceId,
userId: internalContext.userId,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
});
};
}
}

View File

@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import {
DeleteOneResolverArgs,
Resolver,
} from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceSchemaBuilderContext } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import { WorkspaceQueryRunnerService } from 'src/engine/graphql/workspace-query-runner/workspace-query-runner.service';
@Injectable()
export class DeleteOneResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'deleteOne' as const;
constructor(
private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService,
) {}
create(
context: WorkspaceSchemaBuilderContext,
): Resolver<DeleteOneResolverArgs> {
const internalContext = context;
return (_source, args, context, info) => {
return this.workspaceQueryRunnerService.deleteOne(args, {
objectMetadataItem: internalContext.objectMetadataItem,
workspaceId: internalContext.workspaceId,
userId: internalContext.userId,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
});
};
}
}

View File

@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
import {
Resolver,
FindOneResolverArgs,
ExecuteQuickActionOnOneResolverArgs,
DeleteOneResolverArgs,
} from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { Record as IRecord } from 'src/engine/graphql/workspace-query-builder/interfaces/record.interface';
import { WorkspaceSchemaBuilderContext } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import { WorkspaceQueryRunnerOptions } from 'src/engine/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { WorkspaceQueryRunnerService } from 'src/engine/graphql/workspace-query-runner/workspace-query-runner.service';
import { QuickActionsService } from 'src/engine/modules/quick-actions/quick-actions.service';
@Injectable()
export class ExecuteQuickActionOnOneResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'executeQuickActionOnOne' as const;
constructor(
private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService,
private readonly quickActionsService: QuickActionsService,
) {}
create(
context: WorkspaceSchemaBuilderContext,
): Resolver<ExecuteQuickActionOnOneResolverArgs> {
const internalContext = context;
return (_source, args, context, info) => {
return this.executeQuickActionOnOne(args, {
objectMetadataItem: internalContext.objectMetadataItem,
userId: internalContext.userId,
workspaceId: internalContext.workspaceId,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
});
};
}
private async executeQuickActionOnOne<Record extends IRecord = IRecord>(
args: DeleteOneResolverArgs,
options: WorkspaceQueryRunnerOptions,
): Promise<Record | undefined> {
switch (options.objectMetadataItem.nameSingular) {
case 'company': {
await this.quickActionsService.executeQuickActionOnCompany(
args.id,
options.workspaceId,
options.objectMetadataItem,
);
break;
}
case 'person': {
await this.quickActionsService.createCompanyFromPerson(
args.id,
options.workspaceId,
options.objectMetadataCollection,
);
break;
}
default:
// TODO: different quick actions per object on frontend
break;
}
return this.workspaceQueryRunnerService.findOne(
{ filter: { id: { eq: args.id } } } as FindOneResolverArgs,
options,
);
}
}

View File

@ -0,0 +1,41 @@
import { UpdateManyResolverFactory } from 'src/engine/graphql/workspace-resolver-builder/factories/update-many-resolver.factory';
import { FindDuplicatesResolverFactory } from './find-duplicates-resolver.factory';
import { FindManyResolverFactory } from './find-many-resolver.factory';
import { FindOneResolverFactory } from './find-one-resolver.factory';
import { CreateManyResolverFactory } from './create-many-resolver.factory';
import { CreateOneResolverFactory } from './create-one-resolver.factory';
import { UpdateOneResolverFactory } from './update-one-resolver.factory';
import { DeleteOneResolverFactory } from './delete-one-resolver.factory';
import { DeleteManyResolverFactory } from './delete-many-resolver.factory';
import { ExecuteQuickActionOnOneResolverFactory } from './execute-quick-action-on-one-resolver.factory';
export const workspaceResolverBuilderFactories = [
FindManyResolverFactory,
FindOneResolverFactory,
FindDuplicatesResolverFactory,
CreateManyResolverFactory,
CreateOneResolverFactory,
UpdateOneResolverFactory,
DeleteOneResolverFactory,
ExecuteQuickActionOnOneResolverFactory,
UpdateManyResolverFactory,
DeleteManyResolverFactory,
];
export const workspaceResolverBuilderMethodNames = {
queries: [
FindManyResolverFactory.methodName,
FindOneResolverFactory.methodName,
FindDuplicatesResolverFactory.methodName,
],
mutations: [
CreateManyResolverFactory.methodName,
CreateOneResolverFactory.methodName,
UpdateOneResolverFactory.methodName,
DeleteOneResolverFactory.methodName,
ExecuteQuickActionOnOneResolverFactory.methodName,
UpdateManyResolverFactory.methodName,
DeleteManyResolverFactory.methodName,
],
} as const;

View File

@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import {
FindDuplicatesResolverArgs,
Resolver,
} from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceSchemaBuilderContext } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import { WorkspaceQueryRunnerService } from 'src/engine/graphql/workspace-query-runner/workspace-query-runner.service';
@Injectable()
export class FindDuplicatesResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'findDuplicates' as const;
constructor(
private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService,
) {}
create(
context: WorkspaceSchemaBuilderContext,
): Resolver<FindDuplicatesResolverArgs> {
const internalContext = context;
return (_source, args, context, info) => {
return this.workspaceQueryRunnerService.findDuplicates(args, {
objectMetadataItem: internalContext.objectMetadataItem,
workspaceId: internalContext.workspaceId,
userId: internalContext.userId,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
});
};
}
}

View File

@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import {
FindManyResolverArgs,
Resolver,
} from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceSchemaBuilderContext } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import { WorkspaceQueryRunnerService } from 'src/engine/graphql/workspace-query-runner/workspace-query-runner.service';
@Injectable()
export class FindManyResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'findMany' as const;
constructor(
private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService,
) {}
create(
context: WorkspaceSchemaBuilderContext,
): Resolver<FindManyResolverArgs> {
const internalContext = context;
return (_source, args, context, info) => {
return this.workspaceQueryRunnerService.findMany(args, {
objectMetadataItem: internalContext.objectMetadataItem,
workspaceId: internalContext.workspaceId,
userId: internalContext.userId,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
});
};
}
}

View File

@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import {
FindOneResolverArgs,
Resolver,
} from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceSchemaBuilderContext } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import { WorkspaceQueryRunnerService } from 'src/engine/graphql/workspace-query-runner/workspace-query-runner.service';
@Injectable()
export class FindOneResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'findOne' as const;
constructor(
private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService,
) {}
create(
context: WorkspaceSchemaBuilderContext,
): Resolver<FindOneResolverArgs> {
const internalContext = context;
return (_source, args, context, info) => {
return this.workspaceQueryRunnerService.findOne(args, {
objectMetadataItem: internalContext.objectMetadataItem,
workspaceId: internalContext.workspaceId,
userId: internalContext.userId,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
});
};
}
}

View File

@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import {
Resolver,
UpdateManyResolverArgs,
} from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceSchemaBuilderContext } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import { WorkspaceQueryRunnerService } from 'src/engine/graphql/workspace-query-runner/workspace-query-runner.service';
@Injectable()
export class UpdateManyResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'updateMany' as const;
constructor(
private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService,
) {}
create(
context: WorkspaceSchemaBuilderContext,
): Resolver<UpdateManyResolverArgs> {
const internalContext = context;
return (_source, args, context, info) => {
return this.workspaceQueryRunnerService.updateMany(args, {
objectMetadataItem: internalContext.objectMetadataItem,
workspaceId: internalContext.workspaceId,
userId: internalContext.userId,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
});
};
}
}

View File

@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import {
Resolver,
UpdateOneResolverArgs,
} from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceSchemaBuilderContext } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import { WorkspaceQueryRunnerService } from 'src/engine/graphql/workspace-query-runner/workspace-query-runner.service';
@Injectable()
export class UpdateOneResolverFactory
implements WorkspaceResolverBuilderFactoryInterface
{
public static methodName = 'updateOne' as const;
constructor(
private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService,
) {}
create(
context: WorkspaceSchemaBuilderContext,
): Resolver<UpdateOneResolverArgs> {
const internalContext = context;
return (_source, args, context, info) => {
return this.workspaceQueryRunnerService.updateOne(args, {
objectMetadataItem: internalContext.objectMetadataItem,
workspaceId: internalContext.workspaceId,
userId: internalContext.userId,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
});
};
}
}

View File

@ -0,0 +1,14 @@
import { Record as IRecord } from 'src/engine/graphql/workspace-query-builder/interfaces/record.interface';
export interface PGGraphQLResponse<Data = any> {
resolve: {
data: Data;
};
}
export type PGGraphQLResult<Data = any> = [PGGraphQLResponse<Data>];
export interface PGGraphQLMutation<Record = IRecord> {
affectedRows: number;
records: Record[];
}

View File

@ -0,0 +1,7 @@
import { WorkspaceSchemaBuilderContext } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { Resolver } from './workspace-resolvers-builder.interface';
export interface WorkspaceResolverBuilderFactoryInterface {
create(context: WorkspaceSchemaBuilderContext): Resolver;
}

View File

@ -0,0 +1,91 @@
import { GraphQLFieldResolver } from 'graphql';
import {
Record,
RecordFilter,
RecordOrderBy,
} from 'src/engine/graphql/workspace-query-builder/interfaces/record.interface';
import { workspaceResolverBuilderMethodNames } from 'src/engine/graphql/workspace-resolver-builder/factories/factories';
export type Resolver<Args = any> = GraphQLFieldResolver<any, any, Args>;
export interface FindManyResolverArgs<
Filter extends RecordFilter = RecordFilter,
OrderBy extends RecordOrderBy = RecordOrderBy,
> {
first?: number;
last?: number;
before?: string;
after?: string;
filter?: Filter;
orderBy?: OrderBy;
}
export interface FindOneResolverArgs<Filter = any> {
filter?: Filter;
}
export interface FindDuplicatesResolverArgs<Data extends Record = Record> {
id?: string;
data?: Data;
}
export interface CreateOneResolverArgs<Data extends Record = Record> {
data: Data;
}
export interface CreateManyResolverArgs<Data extends Record = Record> {
data: Data[];
}
export interface UpdateOneResolverArgs<Data extends Record = Record> {
id: string;
data: Data;
}
export interface UpdateManyResolverArgs<
Data extends Record = Record,
Filter = any,
> {
filter: Filter;
data: Data;
}
export interface DeleteOneResolverArgs {
id: string;
}
export interface ExecuteQuickActionOnOneResolverArgs {
id: string;
}
export interface DeleteManyResolverArgs<Filter = any> {
filter: Filter;
}
export type WorkspaceResolverBuilderQueryMethodNames =
(typeof workspaceResolverBuilderMethodNames.queries)[number];
export type WorkspaceResolverBuilderMutationMethodNames =
(typeof workspaceResolverBuilderMethodNames.mutations)[number];
export type WorkspaceResolverBuilderMethodNames =
| WorkspaceResolverBuilderQueryMethodNames
| WorkspaceResolverBuilderMutationMethodNames;
export interface WorkspaceResolverBuilderMethods {
readonly queries: readonly WorkspaceResolverBuilderQueryMethodNames[];
readonly mutations: readonly WorkspaceResolverBuilderMutationMethodNames[];
}
export type ResolverArgs =
| CreateManyResolverArgs
| CreateOneResolverArgs
| DeleteManyResolverArgs
| DeleteOneResolverArgs
| FindManyResolverArgs
| FindOneResolverArgs
| FindDuplicatesResolverArgs
| UpdateManyResolverArgs
| UpdateOneResolverArgs;

View File

@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { WorkspaceQueryRunnerModule } from 'src/engine/graphql/workspace-query-runner/workspace-query-runner.module';
import { QuickActionsModule } from 'src/engine/modules/quick-actions/quick-actions.module';
import { WorkspaceResolverFactory } from './workspace-resolver.factory';
import { workspaceResolverBuilderFactories } from './factories/factories';
@Module({
imports: [WorkspaceQueryRunnerModule, QuickActionsModule],
providers: [...workspaceResolverBuilderFactories, WorkspaceResolverFactory],
exports: [WorkspaceResolverFactory],
})
export class WorkspaceResolverBuilderModule {}

View File

@ -0,0 +1,120 @@
import { Injectable, Logger } from '@nestjs/common';
import { IResolvers } from '@graphql-tools/utils';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { getResolverName } from 'src/engine-workspace/utils/get-resolver-name.util';
import { UpdateManyResolverFactory } from 'src/engine/graphql/workspace-resolver-builder/factories/update-many-resolver.factory';
import { DeleteManyResolverFactory } from 'src/engine/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory';
import { ExecuteQuickActionOnOneResolverFactory } from 'src/engine/graphql/workspace-resolver-builder/factories/execute-quick-action-on-one-resolver.factory';
import { FindDuplicatesResolverFactory } from './factories/find-duplicates-resolver.factory';
import { FindManyResolverFactory } from './factories/find-many-resolver.factory';
import { FindOneResolverFactory } from './factories/find-one-resolver.factory';
import { CreateManyResolverFactory } from './factories/create-many-resolver.factory';
import { CreateOneResolverFactory } from './factories/create-one-resolver.factory';
import { UpdateOneResolverFactory } from './factories/update-one-resolver.factory';
import { DeleteOneResolverFactory } from './factories/delete-one-resolver.factory';
import {
WorkspaceResolverBuilderMethodNames,
WorkspaceResolverBuilderMethods,
} from './interfaces/workspace-resolvers-builder.interface';
import { WorkspaceResolverBuilderFactoryInterface } from './interfaces/workspace-resolver-builder-factory.interface';
@Injectable()
export class WorkspaceResolverFactory {
private readonly logger = new Logger(WorkspaceResolverFactory.name);
constructor(
private readonly findManyResolverFactory: FindManyResolverFactory,
private readonly findOneResolverFactory: FindOneResolverFactory,
private readonly findDuplicatesResolverFactory: FindDuplicatesResolverFactory,
private readonly createManyResolverFactory: CreateManyResolverFactory,
private readonly createOneResolverFactory: CreateOneResolverFactory,
private readonly updateOneResolverFactory: UpdateOneResolverFactory,
private readonly deleteOneResolverFactory: DeleteOneResolverFactory,
private readonly executeQuickActionOnOneResolverFactory: ExecuteQuickActionOnOneResolverFactory,
private readonly updateManyResolverFactory: UpdateManyResolverFactory,
private readonly deleteManyResolverFactory: DeleteManyResolverFactory,
) {}
async create(
workspaceId: string,
userId: string | undefined,
objectMetadataCollection: ObjectMetadataInterface[],
workspaceResolverBuilderMethods: WorkspaceResolverBuilderMethods,
): Promise<IResolvers> {
const factories = new Map<
WorkspaceResolverBuilderMethodNames,
WorkspaceResolverBuilderFactoryInterface
>([
['findMany', this.findManyResolverFactory],
['findOne', this.findOneResolverFactory],
['findDuplicates', this.findDuplicatesResolverFactory],
['createMany', this.createManyResolverFactory],
['createOne', this.createOneResolverFactory],
['updateOne', this.updateOneResolverFactory],
['deleteOne', this.deleteOneResolverFactory],
['executeQuickActionOnOne', this.executeQuickActionOnOneResolverFactory],
['updateMany', this.updateManyResolverFactory],
['deleteMany', this.deleteManyResolverFactory],
]);
const resolvers: IResolvers = {
Query: {},
Mutation: {},
};
for (const objectMetadata of objectMetadataCollection) {
// Generate query resolvers
for (const methodName of workspaceResolverBuilderMethods.queries) {
const resolverName = getResolverName(objectMetadata, methodName);
const resolverFactory = factories.get(methodName);
if (!resolverFactory) {
this.logger.error(`Unknown query resolver type: ${methodName}`, {
objectMetadata,
methodName,
resolverName,
});
throw new Error(`Unknown query resolver type: ${methodName}`);
}
resolvers.Query[resolverName] = resolverFactory.create({
workspaceId,
userId,
objectMetadataItem: objectMetadata,
fieldMetadataCollection: objectMetadata.fields,
objectMetadataCollection: objectMetadataCollection,
});
}
// Generate mutation resolvers
for (const methodName of workspaceResolverBuilderMethods.mutations) {
const resolverName = getResolverName(objectMetadata, methodName);
const resolverFactory = factories.get(methodName);
if (!resolverFactory) {
this.logger.error(`Unknown mutation resolver type: ${methodName}`, {
objectMetadata,
methodName,
resolverName,
});
throw new Error(`Unknown mutation resolver type: ${methodName}`);
}
resolvers.Mutation[resolverName] = resolverFactory.create({
workspaceId,
userId,
objectMetadataItem: objectMetadata,
fieldMetadataCollection: objectMetadata.fields,
objectMetadataCollection: objectMetadataCollection,
});
}
}
return resolvers;
}
}

View File

@ -0,0 +1,99 @@
import { Injectable, Logger } from '@nestjs/common';
import { GraphQLFieldConfigArgumentMap } from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { ArgsMetadata } from 'src/engine/graphql/workspace-schema-builder/interfaces/param-metadata.interface';
import { TypeDefinitionsStorage } from 'src/engine/graphql/workspace-schema-builder/storages/type-definitions.storage';
import { TypeMapperService } from 'src/engine/graphql/workspace-schema-builder/services/type-mapper.service';
@Injectable()
export class ArgsFactory {
private readonly logger = new Logger(ArgsFactory.name);
constructor(
private readonly typeDefinitionsStorage: TypeDefinitionsStorage,
private readonly typeMapperService: TypeMapperService,
) {}
public create(
{ args, objectMetadataId }: ArgsMetadata,
options: WorkspaceBuildSchemaOptions,
): GraphQLFieldConfigArgumentMap {
const fieldConfigMap: GraphQLFieldConfigArgumentMap = {};
for (const key in args) {
if (!args.hasOwnProperty(key)) {
continue;
}
const arg = args[key];
// Argument is a scalar type
if (arg.type) {
const fieldType = this.typeMapperService.mapToScalarType(
arg.type,
options.dateScalarMode,
options.numberScalarMode,
);
if (!fieldType) {
this.logger.error(
`Could not find a GraphQL type for ${arg.type.toString()}`,
{
arg,
options,
},
);
throw new Error(
`Could not find a GraphQL type for ${arg.type.toString()}`,
);
}
const gqlType = this.typeMapperService.mapToGqlType(fieldType, {
defaultValue: arg.defaultValue,
nullable: arg.isNullable,
isArray: arg.isArray,
});
fieldConfigMap[key] = {
type: gqlType,
};
}
// Argument is an input type
if (arg.kind) {
const inputType = this.typeDefinitionsStorage.getInputTypeByKey(
objectMetadataId,
arg.kind,
);
if (!inputType) {
this.logger.error(
`Could not find a GraphQL input type for ${objectMetadataId}`,
{
objectMetadataId,
options,
},
);
throw new Error(
`Could not find a GraphQL input type for ${objectMetadataId}`,
);
}
const gqlType = this.typeMapperService.mapToGqlType(inputType, {
nullable: arg.isNullable,
isArray: arg.isArray,
});
fieldConfigMap[key] = {
type: gqlType,
};
}
}
return fieldConfigMap;
}
}

View File

@ -0,0 +1,81 @@
import { Injectable, Logger } from '@nestjs/common';
import { GraphQLFieldConfigMap, GraphQLInt, GraphQLObjectType } from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { pascalCase } from 'src/utils/pascal-case';
import {
ObjectTypeDefinition,
ObjectTypeDefinitionKind,
} from './object-type-definition.factory';
import { ConnectionTypeFactory } from './connection-type.factory';
export enum ConnectionTypeDefinitionKind {
Edge = 'Edge',
PageInfo = 'PageInfo',
}
@Injectable()
export class ConnectionTypeDefinitionFactory {
private readonly logger = new Logger(ConnectionTypeDefinitionFactory.name);
constructor(private readonly connectionTypeFactory: ConnectionTypeFactory) {}
public create(
objectMetadata: ObjectMetadataInterface,
options: WorkspaceBuildSchemaOptions,
): ObjectTypeDefinition {
const kind = ObjectTypeDefinitionKind.Connection;
return {
target: objectMetadata.id,
kind,
type: new GraphQLObjectType({
name: `${pascalCase(objectMetadata.nameSingular)}${kind.toString()}`,
description: objectMetadata.description,
fields: () => this.generateFields(objectMetadata, options),
}),
};
}
private generateFields(
objectMetadata: ObjectMetadataInterface,
options: WorkspaceBuildSchemaOptions,
): GraphQLFieldConfigMap<any, any> {
const fields: GraphQLFieldConfigMap<any, any> = {};
fields.edges = {
type: this.connectionTypeFactory.create(
objectMetadata,
ConnectionTypeDefinitionKind.Edge,
options,
{
isArray: true,
arrayDepth: 1,
nullable: false,
},
),
};
fields.pageInfo = {
type: this.connectionTypeFactory.create(
objectMetadata,
ConnectionTypeDefinitionKind.PageInfo,
options,
{
nullable: false,
},
),
};
fields.totalCount = {
type: GraphQLInt,
description: 'Total number of records in the connection',
};
return fields;
}
}

View File

@ -0,0 +1,58 @@
import { Injectable, Logger } from '@nestjs/common';
import { GraphQLOutputType } from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import {
TypeMapperService,
TypeOptions,
} from 'src/engine/graphql/workspace-schema-builder/services/type-mapper.service';
import { TypeDefinitionsStorage } from 'src/engine/graphql/workspace-schema-builder/storages/type-definitions.storage';
import { PageInfoType } from 'src/engine/graphql/workspace-schema-builder/graphql-types/object';
import { ConnectionTypeDefinitionKind } from './connection-type-definition.factory';
import { ObjectTypeDefinitionKind } from './object-type-definition.factory';
@Injectable()
export class ConnectionTypeFactory {
private readonly logger = new Logger(ConnectionTypeFactory.name);
constructor(
private readonly typeMapperService: TypeMapperService,
private readonly typeDefinitionsStorage: TypeDefinitionsStorage,
) {}
public create(
objectMetadata: ObjectMetadataInterface,
kind: ConnectionTypeDefinitionKind,
buildOtions: WorkspaceBuildSchemaOptions,
typeOptions: TypeOptions,
): GraphQLOutputType {
if (kind === ConnectionTypeDefinitionKind.PageInfo) {
return this.typeMapperService.mapToGqlType(PageInfoType, typeOptions);
}
const edgeType = this.typeDefinitionsStorage.getObjectTypeByKey(
objectMetadata.id,
kind as unknown as ObjectTypeDefinitionKind,
);
if (!edgeType) {
this.logger.error(
`Edge type for ${objectMetadata.nameSingular} was not found. Please, check if you have defined it.`,
{
objectMetadata,
buildOtions,
},
);
throw new Error(
`Edge type for ${objectMetadata.nameSingular} was not found. Please, check if you have defined it.`,
);
}
return this.typeMapperService.mapToGqlType(edgeType, typeOptions);
}
}

View File

@ -0,0 +1,74 @@
import { Injectable, Logger } from '@nestjs/common';
import { GraphQLFieldConfigMap, GraphQLObjectType } from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { pascalCase } from 'src/utils/pascal-case';
import {
ObjectTypeDefinition,
ObjectTypeDefinitionKind,
} from './object-type-definition.factory';
import { EdgeTypeFactory } from './edge-type.factory';
export enum EdgeTypeDefinitionKind {
Node = 'Node',
Cursor = 'Cursor',
}
@Injectable()
export class EdgeTypeDefinitionFactory {
private readonly logger = new Logger(EdgeTypeDefinitionFactory.name);
constructor(private readonly edgeTypeFactory: EdgeTypeFactory) {}
public create(
objectMetadata: ObjectMetadataInterface,
options: WorkspaceBuildSchemaOptions,
): ObjectTypeDefinition {
const kind = ObjectTypeDefinitionKind.Edge;
return {
target: objectMetadata.id,
kind,
type: new GraphQLObjectType({
name: `${pascalCase(objectMetadata.nameSingular)}${kind.toString()}`,
description: objectMetadata.description,
fields: () => this.generateFields(objectMetadata, options),
}),
};
}
private generateFields(
objectMetadata: ObjectMetadataInterface,
options: WorkspaceBuildSchemaOptions,
): GraphQLFieldConfigMap<any, any> {
const fields: GraphQLFieldConfigMap<any, any> = {};
fields.node = {
type: this.edgeTypeFactory.create(
objectMetadata,
EdgeTypeDefinitionKind.Node,
options,
{
nullable: false,
},
),
};
fields.cursor = {
type: this.edgeTypeFactory.create(
objectMetadata,
EdgeTypeDefinitionKind.Cursor,
options,
{
nullable: false,
},
),
};
return fields;
}
}

View File

@ -0,0 +1,58 @@
import { Injectable, Logger } from '@nestjs/common';
import { GraphQLOutputType } from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import {
TypeMapperService,
TypeOptions,
} from 'src/engine/graphql/workspace-schema-builder/services/type-mapper.service';
import { TypeDefinitionsStorage } from 'src/engine/graphql/workspace-schema-builder/storages/type-definitions.storage';
import { CursorScalarType } from 'src/engine/graphql/workspace-schema-builder/graphql-types/scalars';
import { ObjectTypeDefinitionKind } from './object-type-definition.factory';
import { EdgeTypeDefinitionKind } from './edge-type-definition.factory';
@Injectable()
export class EdgeTypeFactory {
private readonly logger = new Logger(EdgeTypeFactory.name);
constructor(
private readonly typeMapperService: TypeMapperService,
private readonly typeDefinitionsStorage: TypeDefinitionsStorage,
) {}
public create(
objectMetadata: ObjectMetadataInterface,
kind: EdgeTypeDefinitionKind,
buildOtions: WorkspaceBuildSchemaOptions,
typeOptions: TypeOptions,
): GraphQLOutputType {
if (kind === EdgeTypeDefinitionKind.Cursor) {
return this.typeMapperService.mapToGqlType(CursorScalarType, typeOptions);
}
const objectType = this.typeDefinitionsStorage.getObjectTypeByKey(
objectMetadata.id,
ObjectTypeDefinitionKind.Plain,
);
if (!objectType) {
this.logger.error(
`Node type for ${objectMetadata.nameSingular} was not found. Please, check if you have defined it.`,
{
objectMetadata,
buildOtions,
},
);
throw new Error(
`Node type for ${objectMetadata.nameSingular} was not found. Please, check if you have defined it.`,
);
}
return this.typeMapperService.mapToGqlType(objectType, typeOptions);
}
}

View File

@ -0,0 +1,89 @@
import { Injectable, Logger } from '@nestjs/common';
import { GraphQLEnumType } from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import { pascalCase } from 'src/utils/pascal-case';
import {
FieldMetadataComplexOption,
FieldMetadataDefaultOption,
} from 'src/engine-metadata/field-metadata/dtos/options.input';
import { isEnumFieldMetadataType } from 'src/engine-metadata/field-metadata/utils/is-enum-field-metadata-type.util';
export interface EnumTypeDefinition {
target: string;
type: GraphQLEnumType;
}
@Injectable()
export class EnumTypeDefinitionFactory {
private readonly logger = new Logger(EnumTypeDefinitionFactory.name);
public create(
objectMetadata: ObjectMetadataInterface,
options: WorkspaceBuildSchemaOptions,
): EnumTypeDefinition[] {
const enumTypeDefinitions: EnumTypeDefinition[] = [];
for (const fieldMetadata of objectMetadata.fields) {
if (!isEnumFieldMetadataType(fieldMetadata.type)) {
continue;
}
enumTypeDefinitions.push({
target: fieldMetadata.id,
type: this.generateEnum(
objectMetadata.nameSingular,
fieldMetadata,
options,
),
});
}
return enumTypeDefinitions;
}
private generateEnum(
objectName: string,
fieldMetadata: FieldMetadataInterface,
options: WorkspaceBuildSchemaOptions,
): GraphQLEnumType {
// FixMe: It's a hack until Typescript get fixed on union types for reduce function
// https://github.com/microsoft/TypeScript/issues/36390
const enumOptions = fieldMetadata.options as Array<
FieldMetadataDefaultOption | FieldMetadataComplexOption
>;
if (!enumOptions) {
this.logger.error(
`Enum options are not defined for ${fieldMetadata.name}`,
{
fieldMetadata,
options,
},
);
throw new Error(`Enum options are not defined for ${fieldMetadata.name}`);
}
return new GraphQLEnumType({
name: `${pascalCase(objectName)}${pascalCase(fieldMetadata.name)}Enum`,
description: fieldMetadata.description,
values: enumOptions.reduce(
(acc, enumOption) => {
// Key must match this regex: /^[_A-Za-z][_0-9A-Za-z]+$/
acc[enumOption.value] = {
value: enumOption.value,
description: enumOption.label,
};
return acc;
},
{} as { [key: string]: { value: string; description: string } },
),
});
}
}

View File

@ -0,0 +1,166 @@
import { Injectable, Logger } from '@nestjs/common';
import {
GraphQLFieldConfigArgumentMap,
GraphQLFieldConfigMap,
GraphQLObjectType,
} from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { TypeDefinitionsStorage } from 'src/engine/graphql/workspace-schema-builder/storages/type-definitions.storage';
import { objectContainsRelationField } from 'src/engine/graphql/workspace-schema-builder/utils/object-contains-relation-field';
import { getResolverArgs } from 'src/engine/graphql/workspace-schema-builder/utils/get-resolver-args.util';
import { isRelationFieldMetadataType } from 'src/engine-workspace/utils/is-relation-field-metadata-type.util';
import {
RelationDirection,
deduceRelationDirection,
} from 'src/engine-workspace/utils/deduce-relation-direction.util';
import { RelationMetadataType } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
import { RelationTypeFactory } from './relation-type.factory';
import { ArgsFactory } from './args.factory';
export enum ObjectTypeDefinitionKind {
Connection = 'Connection',
Edge = 'Edge',
Plain = '',
}
export interface ObjectTypeDefinition {
target: string;
kind: ObjectTypeDefinitionKind;
type: GraphQLObjectType;
}
@Injectable()
export class ExtendObjectTypeDefinitionFactory {
private readonly logger = new Logger(ExtendObjectTypeDefinitionFactory.name);
constructor(
private readonly relationTypeFactory: RelationTypeFactory,
private readonly argsFactory: ArgsFactory,
private readonly typeDefinitionsStorage: TypeDefinitionsStorage,
) {}
public create(
objectMetadata: ObjectMetadataInterface,
options: WorkspaceBuildSchemaOptions,
): ObjectTypeDefinition {
const kind = ObjectTypeDefinitionKind.Plain;
const gqlType = this.typeDefinitionsStorage.getObjectTypeByKey(
objectMetadata.id,
kind,
);
const containsRelationField = objectContainsRelationField(objectMetadata);
if (!gqlType) {
this.logger.error(
`Could not find a GraphQL type for ${objectMetadata.id.toString()}`,
{
objectMetadata,
options,
},
);
throw new Error(
`Could not find a GraphQL type for ${objectMetadata.id.toString()}`,
);
}
// Security check to avoid extending an object that does not need to be extended
if (!containsRelationField) {
this.logger.error(
`This object does not need to be extended: ${objectMetadata.id.toString()}`,
{
objectMetadata,
options,
},
);
throw new Error(
`This object does not need to be extended: ${objectMetadata.id.toString()}`,
);
}
// Extract current object config to extend it
const config = gqlType.toConfig();
// Recreate the same object type with the new fields
return {
target: objectMetadata.id,
kind,
type: new GraphQLObjectType({
...config,
fields: () => ({
...config.fields,
...this.generateFields(objectMetadata, options),
}),
}),
};
}
private generateFields(
objectMetadata: ObjectMetadataInterface,
options: WorkspaceBuildSchemaOptions,
): GraphQLFieldConfigMap<any, any> {
const fields: GraphQLFieldConfigMap<any, any> = {};
for (const fieldMetadata of objectMetadata.fields) {
// Ignore relation fields as they are already defined
if (!isRelationFieldMetadataType(fieldMetadata.type)) {
continue;
}
const relationMetadata =
fieldMetadata.fromRelationMetadata ?? fieldMetadata.toRelationMetadata;
if (!relationMetadata) {
this.logger.error(
`Could not find a relation metadata for ${fieldMetadata.id}`,
{ fieldMetadata },
);
throw new Error(
`Could not find a relation metadata for ${fieldMetadata.id}`,
);
}
const relationDirection = deduceRelationDirection(
fieldMetadata,
relationMetadata,
);
const relationType = this.relationTypeFactory.create(
fieldMetadata,
relationMetadata,
relationDirection,
);
let argsType: GraphQLFieldConfigArgumentMap | undefined = undefined;
// Args are only needed when relation is of kind `oneToMany` and the relation direction is `from`
if (
relationMetadata.relationType === RelationMetadataType.ONE_TO_MANY &&
relationDirection === RelationDirection.FROM
) {
const args = getResolverArgs('findMany');
argsType = this.argsFactory.create(
{
args,
objectMetadataId: relationMetadata.toObjectMetadataId,
},
options,
);
}
fields[fieldMetadata.name] = {
type: relationType,
args: argsType,
description: fieldMetadata.description,
};
}
return fields;
}
}

View File

@ -0,0 +1,44 @@
import { EnumTypeDefinitionFactory } from 'src/engine/graphql/workspace-schema-builder/factories/enum-type-definition.factory';
import { ArgsFactory } from './args.factory';
import { InputTypeFactory } from './input-type.factory';
import { InputTypeDefinitionFactory } from './input-type-definition.factory';
import { ObjectTypeDefinitionFactory } from './object-type-definition.factory';
import { OutputTypeFactory } from './output-type.factory';
import { QueryTypeFactory } from './query-type.factory';
import { RootTypeFactory } from './root-type.factory';
import { FilterTypeFactory } from './filter-type.factory';
import { FilterTypeDefinitionFactory } from './filter-type-definition.factory';
import { ConnectionTypeFactory } from './connection-type.factory';
import { ConnectionTypeDefinitionFactory } from './connection-type-definition.factory';
import { EdgeTypeFactory } from './edge-type.factory';
import { EdgeTypeDefinitionFactory } from './edge-type-definition.factory';
import { MutationTypeFactory } from './mutation-type.factory';
import { OrderByTypeFactory } from './order-by-type.factory';
import { OrderByTypeDefinitionFactory } from './order-by-type-definition.factory';
import { RelationTypeFactory } from './relation-type.factory';
import { ExtendObjectTypeDefinitionFactory } from './extend-object-type-definition.factory';
import { OrphanedTypesFactory } from './orphaned-types.factory';
export const workspaceSchemaBuilderFactories = [
ArgsFactory,
InputTypeFactory,
InputTypeDefinitionFactory,
OutputTypeFactory,
ObjectTypeDefinitionFactory,
EnumTypeDefinitionFactory,
RelationTypeFactory,
ExtendObjectTypeDefinitionFactory,
FilterTypeFactory,
FilterTypeDefinitionFactory,
OrderByTypeFactory,
OrderByTypeDefinitionFactory,
ConnectionTypeFactory,
ConnectionTypeDefinitionFactory,
EdgeTypeFactory,
EdgeTypeDefinitionFactory,
RootTypeFactory,
QueryTypeFactory,
MutationTypeFactory,
OrphanedTypesFactory,
];

View File

@ -0,0 +1,91 @@
import { Injectable } from '@nestjs/common';
import { GraphQLInputFieldConfigMap, GraphQLInputObjectType } from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { pascalCase } from 'src/utils/pascal-case';
import { TypeMapperService } from 'src/engine/graphql/workspace-schema-builder/services/type-mapper.service';
import { isRelationFieldMetadataType } from 'src/engine-workspace/utils/is-relation-field-metadata-type.util';
import { FilterTypeFactory } from './filter-type.factory';
import {
InputTypeDefinition,
InputTypeDefinitionKind,
} from './input-type-definition.factory';
@Injectable()
export class FilterTypeDefinitionFactory {
constructor(
private readonly filterTypeFactory: FilterTypeFactory,
private readonly typeMapperService: TypeMapperService,
) {}
public create(
objectMetadata: ObjectMetadataInterface,
options: WorkspaceBuildSchemaOptions,
): InputTypeDefinition {
const kind = InputTypeDefinitionKind.Filter;
const filterInputType = new GraphQLInputObjectType({
name: `${pascalCase(objectMetadata.nameSingular)}${kind.toString()}Input`,
description: objectMetadata.description,
fields: () => {
const andOrType = this.typeMapperService.mapToGqlType(filterInputType, {
isArray: true,
arrayDepth: 1,
nullable: true,
});
return {
...this.generateFields(objectMetadata, options),
and: {
type: andOrType,
},
or: {
type: andOrType,
},
not: {
type: this.typeMapperService.mapToGqlType(filterInputType, {
nullable: true,
}),
},
};
},
});
return {
target: objectMetadata.id,
kind,
type: filterInputType,
};
}
private generateFields(
objectMetadata: ObjectMetadataInterface,
options: WorkspaceBuildSchemaOptions,
): GraphQLInputFieldConfigMap {
const fields: GraphQLInputFieldConfigMap = {};
for (const fieldMetadata of objectMetadata.fields) {
// Relation types are generated during extension of object type definition
if (isRelationFieldMetadataType(fieldMetadata.type)) {
continue;
}
const type = this.filterTypeFactory.create(fieldMetadata, options, {
nullable: fieldMetadata.isNullable,
defaultValue: fieldMetadata.defaultValue,
});
fields[fieldMetadata.name] = {
type,
description: fieldMetadata.description,
// TODO: Add default value
defaultValue: undefined,
};
}
return fields;
}
}

View File

@ -0,0 +1,102 @@
import { Injectable, Logger } from '@nestjs/common';
import {
GraphQLInputObjectType,
GraphQLInputType,
GraphQLList,
GraphQLScalarType,
} from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import {
TypeMapperService,
TypeOptions,
} from 'src/engine/graphql/workspace-schema-builder/services/type-mapper.service';
import { TypeDefinitionsStorage } from 'src/engine/graphql/workspace-schema-builder/storages/type-definitions.storage';
import { isCompositeFieldMetadataType } from 'src/engine-metadata/field-metadata/utils/is-composite-field-metadata-type.util';
import { isEnumFieldMetadataType } from 'src/engine-metadata/field-metadata/utils/is-enum-field-metadata-type.util';
import { FilterIs } from 'src/engine/graphql/workspace-schema-builder/graphql-types/input/filter-is.input-type';
import { InputTypeDefinitionKind } from './input-type-definition.factory';
@Injectable()
export class FilterTypeFactory {
private readonly logger = new Logger(FilterTypeFactory.name);
constructor(
private readonly typeMapperService: TypeMapperService,
private readonly typeDefinitionsStorage: TypeDefinitionsStorage,
) {}
public create(
fieldMetadata: FieldMetadataInterface,
buildOptions: WorkspaceBuildSchemaOptions,
typeOptions: TypeOptions,
): GraphQLInputType {
const target = isCompositeFieldMetadataType(fieldMetadata.type)
? fieldMetadata.type.toString()
: fieldMetadata.id;
let filterType: GraphQLInputObjectType | GraphQLScalarType | undefined =
undefined;
if (isEnumFieldMetadataType(fieldMetadata.type)) {
filterType = this.createEnumFilterType(fieldMetadata);
} else {
filterType = this.typeMapperService.mapToFilterType(
fieldMetadata.type,
buildOptions.dateScalarMode,
buildOptions.numberScalarMode,
);
filterType ??= this.typeDefinitionsStorage.getInputTypeByKey(
target,
InputTypeDefinitionKind.Filter,
);
}
if (!filterType) {
this.logger.error(`Could not find a GraphQL type for ${target}`, {
fieldMetadata,
buildOptions,
typeOptions,
});
throw new Error(`Could not find a GraphQL type for ${target}`);
}
return this.typeMapperService.mapToGqlType(filterType, typeOptions);
}
private createEnumFilterType(
fieldMetadata: FieldMetadataInterface,
): GraphQLInputObjectType {
const enumType = this.typeDefinitionsStorage.getEnumTypeByKey(
fieldMetadata.id,
);
if (!enumType) {
this.logger.error(
`Could not find a GraphQL enum type for ${fieldMetadata.id}`,
{
fieldMetadata,
},
);
throw new Error(
`Could not find a GraphQL enum type for ${fieldMetadata.id}`,
);
}
return new GraphQLInputObjectType({
name: `${enumType.name}Filter`,
fields: () => ({
eq: { type: enumType },
neq: { type: enumType },
in: { type: new GraphQLList(enumType) },
is: { type: FilterIs },
}),
});
}
}

View File

@ -0,0 +1,78 @@
import { Injectable } from '@nestjs/common';
import { GraphQLInputFieldConfigMap, GraphQLInputObjectType } from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { pascalCase } from 'src/utils/pascal-case';
import { isRelationFieldMetadataType } from 'src/engine-workspace/utils/is-relation-field-metadata-type.util';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { InputTypeFactory } from './input-type.factory';
export enum InputTypeDefinitionKind {
Create = 'Create',
Update = 'Update',
Filter = 'Filter',
OrderBy = 'OrderBy',
}
export interface InputTypeDefinition {
target: string;
kind: InputTypeDefinitionKind;
type: GraphQLInputObjectType;
}
@Injectable()
export class InputTypeDefinitionFactory {
constructor(private readonly inputTypeFactory: InputTypeFactory) {}
public create(
objectMetadata: ObjectMetadataInterface,
kind: InputTypeDefinitionKind,
options: WorkspaceBuildSchemaOptions,
): InputTypeDefinition {
return {
target: objectMetadata.id,
kind,
type: new GraphQLInputObjectType({
name: `${pascalCase(
objectMetadata.nameSingular,
)}${kind.toString()}Input`,
description: objectMetadata.description,
fields: this.generateFields(objectMetadata, kind, options),
}),
};
}
private generateFields(
objectMetadata: ObjectMetadataInterface,
kind: InputTypeDefinitionKind,
options: WorkspaceBuildSchemaOptions,
): GraphQLInputFieldConfigMap {
const fields: GraphQLInputFieldConfigMap = {};
for (const fieldMetadata of objectMetadata.fields) {
// Relation field types are generated during extension of object type definition
if (isRelationFieldMetadataType(fieldMetadata.type)) {
continue;
}
const type = this.inputTypeFactory.create(fieldMetadata, kind, options, {
nullable: fieldMetadata.isNullable,
defaultValue: fieldMetadata.defaultValue,
isArray: fieldMetadata.type === FieldMetadataType.MULTI_SELECT,
});
fields[fieldMetadata.name] = {
type,
description: fieldMetadata.description,
// TODO: Add default value
defaultValue: undefined,
};
}
return fields;
}
}

View File

@ -0,0 +1,59 @@
import { Injectable, Logger } from '@nestjs/common';
import { GraphQLInputType } from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import {
TypeMapperService,
TypeOptions,
} from 'src/engine/graphql/workspace-schema-builder/services/type-mapper.service';
import { TypeDefinitionsStorage } from 'src/engine/graphql/workspace-schema-builder/storages/type-definitions.storage';
import { isCompositeFieldMetadataType } from 'src/engine-metadata/field-metadata/utils/is-composite-field-metadata-type.util';
import { InputTypeDefinitionKind } from './input-type-definition.factory';
@Injectable()
export class InputTypeFactory {
private readonly logger = new Logger(InputTypeFactory.name);
constructor(
private readonly typeMapperService: TypeMapperService,
private readonly typeDefinitionsStorage: TypeDefinitionsStorage,
) {}
public create(
fieldMetadata: FieldMetadataInterface,
kind: InputTypeDefinitionKind,
buildOtions: WorkspaceBuildSchemaOptions,
typeOptions: TypeOptions,
): GraphQLInputType {
const target = isCompositeFieldMetadataType(fieldMetadata.type)
? fieldMetadata.type.toString()
: fieldMetadata.id;
let inputType: GraphQLInputType | undefined =
this.typeMapperService.mapToScalarType(
fieldMetadata.type,
buildOtions.dateScalarMode,
buildOtions.numberScalarMode,
);
inputType ??= this.typeDefinitionsStorage.getInputTypeByKey(target, kind);
inputType ??= this.typeDefinitionsStorage.getEnumTypeByKey(target);
if (!inputType) {
this.logger.error(`Could not find a GraphQL type for ${target}`, {
fieldMetadata,
kind,
buildOtions,
typeOptions,
});
throw new Error(`Could not find a GraphQL type for ${target}`);
}
return this.typeMapperService.mapToGqlType(inputType, typeOptions);
}
}

View File

@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { GraphQLObjectType } from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { WorkspaceResolverBuilderMutationMethodNames } from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { ObjectTypeName, RootTypeFactory } from './root-type.factory';
@Injectable()
export class MutationTypeFactory {
constructor(private readonly rootTypeFactory: RootTypeFactory) {}
create(
objectMetadataCollection: ObjectMetadataInterface[],
workspaceResolverMethodNames: WorkspaceResolverBuilderMutationMethodNames[],
options: WorkspaceBuildSchemaOptions,
): GraphQLObjectType {
return this.rootTypeFactory.create(
objectMetadataCollection,
workspaceResolverMethodNames,
ObjectTypeName.Mutation,
options,
);
}
}

View File

@ -0,0 +1,72 @@
import { Injectable } from '@nestjs/common';
import { GraphQLFieldConfigMap, GraphQLObjectType } from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { pascalCase } from 'src/utils/pascal-case';
import { isRelationFieldMetadataType } from 'src/engine-workspace/utils/is-relation-field-metadata-type.util';
import { FieldMetadataType } from 'src/engine-metadata/field-metadata/field-metadata.entity';
import { OutputTypeFactory } from './output-type.factory';
export enum ObjectTypeDefinitionKind {
Connection = 'Connection',
Edge = 'Edge',
Plain = '',
}
export interface ObjectTypeDefinition {
target: string;
kind: ObjectTypeDefinitionKind;
type: GraphQLObjectType;
}
@Injectable()
export class ObjectTypeDefinitionFactory {
constructor(private readonly outputTypeFactory: OutputTypeFactory) {}
public create(
objectMetadata: ObjectMetadataInterface,
kind: ObjectTypeDefinitionKind,
options: WorkspaceBuildSchemaOptions,
): ObjectTypeDefinition {
return {
target: objectMetadata.id,
kind,
type: new GraphQLObjectType({
name: `${pascalCase(objectMetadata.nameSingular)}${kind.toString()}`,
description: objectMetadata.description,
fields: this.generateFields(objectMetadata, kind, options),
}),
};
}
private generateFields(
objectMetadata: ObjectMetadataInterface,
kind: ObjectTypeDefinitionKind,
options: WorkspaceBuildSchemaOptions,
): GraphQLFieldConfigMap<any, any> {
const fields: GraphQLFieldConfigMap<any, any> = {};
for (const fieldMetadata of objectMetadata.fields) {
// Relation field types are generated during extension of object type definition
if (isRelationFieldMetadataType(fieldMetadata.type)) {
continue;
}
const type = this.outputTypeFactory.create(fieldMetadata, kind, options, {
nullable: fieldMetadata.isNullable,
isArray: fieldMetadata.type === FieldMetadataType.MULTI_SELECT,
});
fields[fieldMetadata.name] = {
type,
description: fieldMetadata.description,
};
}
return fields;
}
}

View File

@ -0,0 +1,66 @@
import { Injectable } from '@nestjs/common';
import { GraphQLInputFieldConfigMap, GraphQLInputObjectType } from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { pascalCase } from 'src/utils/pascal-case';
import { isRelationFieldMetadataType } from 'src/engine-workspace/utils/is-relation-field-metadata-type.util';
import {
InputTypeDefinition,
InputTypeDefinitionKind,
} from './input-type-definition.factory';
import { OrderByTypeFactory } from './order-by-type.factory';
@Injectable()
export class OrderByTypeDefinitionFactory {
constructor(private readonly orderByTypeFactory: OrderByTypeFactory) {}
public create(
objectMetadata: ObjectMetadataInterface,
options: WorkspaceBuildSchemaOptions,
): InputTypeDefinition {
const kind = InputTypeDefinitionKind.OrderBy;
return {
target: objectMetadata.id,
kind,
type: new GraphQLInputObjectType({
name: `${pascalCase(
objectMetadata.nameSingular,
)}${kind.toString()}Input`,
description: objectMetadata.description,
fields: this.generateFields(objectMetadata, options),
}),
};
}
private generateFields(
objectMetadata: ObjectMetadataInterface,
options: WorkspaceBuildSchemaOptions,
): GraphQLInputFieldConfigMap {
const fields: GraphQLInputFieldConfigMap = {};
for (const fieldMetadata of objectMetadata.fields) {
// Relation field types are generated during extension of object type definition
if (isRelationFieldMetadataType(fieldMetadata.type)) {
continue;
}
const type = this.orderByTypeFactory.create(fieldMetadata, options, {
nullable: fieldMetadata.isNullable,
});
fields[fieldMetadata.name] = {
type,
description: fieldMetadata.description,
// TODO: Add default value
defaultValue: undefined,
};
}
return fields;
}
}

View File

@ -0,0 +1,55 @@
import { Injectable, Logger } from '@nestjs/common';
import { GraphQLInputType } from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import {
TypeMapperService,
TypeOptions,
} from 'src/engine/graphql/workspace-schema-builder/services/type-mapper.service';
import { TypeDefinitionsStorage } from 'src/engine/graphql/workspace-schema-builder/storages/type-definitions.storage';
import { isCompositeFieldMetadataType } from 'src/engine-metadata/field-metadata/utils/is-composite-field-metadata-type.util';
import { InputTypeDefinitionKind } from './input-type-definition.factory';
@Injectable()
export class OrderByTypeFactory {
private readonly logger = new Logger(OrderByTypeFactory.name);
constructor(
private readonly typeMapperService: TypeMapperService,
private readonly typeDefinitionsStorage: TypeDefinitionsStorage,
) {}
public create(
fieldMetadata: FieldMetadataInterface,
buildOtions: WorkspaceBuildSchemaOptions,
typeOptions: TypeOptions,
): GraphQLInputType {
const target = isCompositeFieldMetadataType(fieldMetadata.type)
? fieldMetadata.type.toString()
: fieldMetadata.id;
let orderByType = this.typeMapperService.mapToOrderByType(
fieldMetadata.type,
);
orderByType ??= this.typeDefinitionsStorage.getInputTypeByKey(
target,
InputTypeDefinitionKind.OrderBy,
);
if (!orderByType) {
this.logger.error(`Could not find a GraphQL type for ${target}`, {
fieldMetadata,
buildOtions,
typeOptions,
});
throw new Error(`Could not find a GraphQL type for ${target}`);
}
return this.typeMapperService.mapToGqlType(orderByType, typeOptions);
}
}

View File

@ -0,0 +1,22 @@
import { Injectable } from '@nestjs/common';
import { GraphQLNamedType } from 'graphql';
import { TypeDefinitionsStorage } from 'src/engine/graphql/workspace-schema-builder/storages/type-definitions.storage';
@Injectable()
export class OrphanedTypesFactory {
constructor(
private readonly typeDefinitionsStorage: TypeDefinitionsStorage,
) {}
public create(): GraphQLNamedType[] {
const objectTypeDefs =
this.typeDefinitionsStorage.getAllObjectTypeDefinitions();
const inputTypeDefs =
this.typeDefinitionsStorage.getAllInputTypeDefinitions();
const classTypeDefs = [...objectTypeDefs, ...inputTypeDefs];
return [...classTypeDefs.map(({ type }) => type)];
}
}

View File

@ -0,0 +1,58 @@
import { Injectable, Logger } from '@nestjs/common';
import { GraphQLOutputType } from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import {
TypeMapperService,
TypeOptions,
} from 'src/engine/graphql/workspace-schema-builder/services/type-mapper.service';
import { TypeDefinitionsStorage } from 'src/engine/graphql/workspace-schema-builder/storages/type-definitions.storage';
import { isCompositeFieldMetadataType } from 'src/engine-metadata/field-metadata/utils/is-composite-field-metadata-type.util';
import { ObjectTypeDefinitionKind } from './object-type-definition.factory';
@Injectable()
export class OutputTypeFactory {
private readonly logger = new Logger(OutputTypeFactory.name);
constructor(
private readonly typeMapperService: TypeMapperService,
private readonly typeDefinitionsStorage: TypeDefinitionsStorage,
) {}
public create(
fieldMetadata: FieldMetadataInterface,
kind: ObjectTypeDefinitionKind,
buildOtions: WorkspaceBuildSchemaOptions,
typeOptions: TypeOptions,
): GraphQLOutputType {
const target = isCompositeFieldMetadataType(fieldMetadata.type)
? fieldMetadata.type.toString()
: fieldMetadata.id;
let gqlType: GraphQLOutputType | undefined =
this.typeMapperService.mapToScalarType(
fieldMetadata.type,
buildOtions.dateScalarMode,
buildOtions.numberScalarMode,
);
gqlType ??= this.typeDefinitionsStorage.getObjectTypeByKey(target, kind);
gqlType ??= this.typeDefinitionsStorage.getEnumTypeByKey(target);
if (!gqlType) {
this.logger.error(`Could not find a GraphQL type for ${target}`, {
fieldMetadata,
buildOtions,
typeOptions,
});
throw new Error(`Could not find a GraphQL type for ${target}`);
}
return this.typeMapperService.mapToGqlType(gqlType, typeOptions);
}
}

View File

@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { GraphQLObjectType } from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { WorkspaceResolverBuilderQueryMethodNames } from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { ObjectTypeName, RootTypeFactory } from './root-type.factory';
@Injectable()
export class QueryTypeFactory {
constructor(private readonly rootTypeFactory: RootTypeFactory) {}
create(
objectMetadataCollection: ObjectMetadataInterface[],
workspaceResolverMethodNames: WorkspaceResolverBuilderQueryMethodNames[],
options: WorkspaceBuildSchemaOptions,
): GraphQLObjectType {
return this.rootTypeFactory.create(
objectMetadataCollection,
workspaceResolverMethodNames,
ObjectTypeName.Query,
options,
);
}
}

View File

@ -0,0 +1,62 @@
import { Injectable, Logger } from '@nestjs/common';
import { GraphQLOutputType } from 'graphql';
import { FieldMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/field-metadata.interface';
import { RelationMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/relation-metadata.interface';
import { RelationMetadataType } from 'src/engine-metadata/relation-metadata/relation-metadata.entity';
import { TypeDefinitionsStorage } from 'src/engine/graphql/workspace-schema-builder/storages/type-definitions.storage';
import { RelationDirection } from 'src/engine-workspace/utils/deduce-relation-direction.util';
import { ObjectTypeDefinitionKind } from './object-type-definition.factory';
@Injectable()
export class RelationTypeFactory {
private readonly logger = new Logger(RelationTypeFactory.name);
constructor(
private readonly typeDefinitionsStorage: TypeDefinitionsStorage,
) {}
public create(
fieldMetadata: FieldMetadataInterface,
relationMetadata: RelationMetadataInterface,
relationDirection: RelationDirection,
): GraphQLOutputType {
let relationQqlType: GraphQLOutputType | undefined = undefined;
if (
relationDirection === RelationDirection.FROM &&
relationMetadata.relationType === RelationMetadataType.ONE_TO_MANY
) {
relationQqlType = this.typeDefinitionsStorage.getObjectTypeByKey(
relationMetadata.toObjectMetadataId,
ObjectTypeDefinitionKind.Connection,
);
} else {
const relationObjectId =
relationDirection === RelationDirection.FROM
? relationMetadata.toObjectMetadataId
: relationMetadata.fromObjectMetadataId;
relationQqlType = this.typeDefinitionsStorage.getObjectTypeByKey(
relationObjectId,
ObjectTypeDefinitionKind.Plain,
);
}
if (!relationQqlType) {
this.logger.error(
`Could not find a relation type for ${fieldMetadata.id}`,
{
fieldMetadata,
},
);
throw new Error(`Could not find a relation type for ${fieldMetadata.id}`);
}
return relationQqlType;
}
}

View File

@ -0,0 +1,120 @@
import { Injectable, Logger } from '@nestjs/common';
import { GraphQLFieldConfigMap, GraphQLObjectType } from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { WorkspaceResolverBuilderMethodNames } from 'src/engine/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { ObjectMetadataInterface } from 'src/engine-metadata/field-metadata/interfaces/object-metadata.interface';
import { TypeDefinitionsStorage } from 'src/engine/graphql/workspace-schema-builder/storages/type-definitions.storage';
import { getResolverName } from 'src/engine-workspace/utils/get-resolver-name.util';
import { getResolverArgs } from 'src/engine/graphql/workspace-schema-builder/utils/get-resolver-args.util';
import { TypeMapperService } from 'src/engine/graphql/workspace-schema-builder/services/type-mapper.service';
import { ArgsFactory } from './args.factory';
import { ObjectTypeDefinitionKind } from './object-type-definition.factory';
export enum ObjectTypeName {
Query = 'Query',
Mutation = 'Mutation',
Subscription = 'Subscription',
}
@Injectable()
export class RootTypeFactory {
private readonly logger = new Logger(RootTypeFactory.name);
constructor(
private readonly typeDefinitionsStorage: TypeDefinitionsStorage,
private readonly typeMapperService: TypeMapperService,
private readonly argsFactory: ArgsFactory,
) {}
create(
objectMetadataCollection: ObjectMetadataInterface[],
workspaceResolverMethodNames: WorkspaceResolverBuilderMethodNames[],
objectTypeName: ObjectTypeName,
options: WorkspaceBuildSchemaOptions,
): GraphQLObjectType {
if (workspaceResolverMethodNames.length === 0) {
this.logger.error(
`No resolver methods were found for ${objectTypeName.toString()}`,
{
workspaceResolverMethodNames,
objectTypeName,
options,
},
);
throw new Error(
`No resolvers were found for ${objectTypeName.toString()}`,
);
}
return new GraphQLObjectType({
name: objectTypeName.toString(),
fields: this.generateFields(
objectMetadataCollection,
workspaceResolverMethodNames,
options,
),
});
}
private generateFields<T = any, U = any>(
objectMetadataCollection: ObjectMetadataInterface[],
workspaceResolverMethodNames: WorkspaceResolverBuilderMethodNames[],
options: WorkspaceBuildSchemaOptions,
): GraphQLFieldConfigMap<T, U> {
const fieldConfigMap: GraphQLFieldConfigMap<T, U> = {};
for (const objectMetadata of objectMetadataCollection) {
for (const methodName of workspaceResolverMethodNames) {
const name = getResolverName(objectMetadata, methodName);
const args = getResolverArgs(methodName);
const objectType = this.typeDefinitionsStorage.getObjectTypeByKey(
objectMetadata.id,
['findMany', 'findDuplicates'].includes(methodName)
? ObjectTypeDefinitionKind.Connection
: ObjectTypeDefinitionKind.Plain,
);
const argsType = this.argsFactory.create(
{
args,
objectMetadataId: objectMetadata.id,
},
options,
);
if (!objectType) {
this.logger.error(
`Could not find a GraphQL type for ${objectMetadata.id} for method ${methodName}`,
{
objectMetadata,
methodName,
options,
},
);
throw new Error(
`Could not find a GraphQL type for ${objectMetadata.id} for method ${methodName}`,
);
}
const outputType = this.typeMapperService.mapToGqlType(objectType, {
isArray: ['updateMany', 'deleteMany', 'createMany'].includes(
methodName,
),
});
fieldConfigMap[name] = {
type: outputType,
args: argsType,
resolve: undefined,
};
}
}
return fieldConfigMap;
}
}

View File

@ -0,0 +1 @@
export * from './order-by-direction.enum-type';

View File

@ -0,0 +1,24 @@
import { GraphQLEnumType } from 'graphql';
export const OrderByDirectionType = new GraphQLEnumType({
name: 'OrderByDirection',
description: 'This enum is used to specify the order of results',
values: {
AscNullsFirst: {
value: 'AscNullsFirst',
description: 'Ascending order, nulls first',
},
AscNullsLast: {
value: 'AscNullsLast',
description: 'Ascending order, nulls last',
},
DescNullsFirst: {
value: 'DescNullsFirst',
description: 'Descending order, nulls first',
},
DescNullsLast: {
value: 'DescNullsLast',
description: 'Descending order, nulls last',
},
},
});

View File

@ -0,0 +1,18 @@
import { GraphQLInputObjectType, GraphQLList, GraphQLNonNull } from 'graphql';
import { FilterIs } from 'src/engine/graphql/workspace-schema-builder/graphql-types/input/filter-is.input-type';
import { BigFloatScalarType } from 'src/engine/graphql/workspace-schema-builder/graphql-types/scalars';
export const BigFloatFilterType = new GraphQLInputObjectType({
name: 'BigFloatFilter',
fields: {
eq: { type: BigFloatScalarType },
gt: { type: BigFloatScalarType },
gte: { type: BigFloatScalarType },
in: { type: new GraphQLList(new GraphQLNonNull(BigFloatScalarType)) },
lt: { type: BigFloatScalarType },
lte: { type: BigFloatScalarType },
neq: { type: BigFloatScalarType },
is: { type: FilterIs },
},
});

View File

@ -0,0 +1,22 @@
import {
GraphQLInputObjectType,
GraphQLList,
GraphQLNonNull,
GraphQLInt,
} from 'graphql';
import { FilterIs } from 'src/engine/graphql/workspace-schema-builder/graphql-types/input/filter-is.input-type';
export const BigIntFilterType = new GraphQLInputObjectType({
name: 'BigIntFilter',
fields: {
eq: { type: GraphQLInt },
gt: { type: GraphQLInt },
gte: { type: GraphQLInt },
in: { type: new GraphQLList(new GraphQLNonNull(GraphQLInt)) },
lt: { type: GraphQLInt },
lte: { type: GraphQLInt },
neq: { type: GraphQLInt },
is: { type: FilterIs },
},
});

View File

@ -0,0 +1,11 @@
import { GraphQLBoolean, GraphQLInputObjectType } from 'graphql';
import { FilterIs } from 'src/engine/graphql/workspace-schema-builder/graphql-types/input/filter-is.input-type';
export const BooleanFilterType = new GraphQLInputObjectType({
name: 'BooleanFilter',
fields: {
eq: { type: GraphQLBoolean },
is: { type: FilterIs },
},
});

View File

@ -0,0 +1,18 @@
import { GraphQLInputObjectType, GraphQLList, GraphQLNonNull } from 'graphql';
import { FilterIs } from 'src/engine/graphql/workspace-schema-builder/graphql-types/input/filter-is.input-type';
import { DateScalarType } from 'src/engine/graphql/workspace-schema-builder/graphql-types/scalars';
export const DateFilterType = new GraphQLInputObjectType({
name: 'DateFilter',
fields: {
eq: { type: DateScalarType },
gt: { type: DateScalarType },
gte: { type: DateScalarType },
in: { type: new GraphQLList(new GraphQLNonNull(DateScalarType)) },
lt: { type: DateScalarType },
lte: { type: DateScalarType },
neq: { type: DateScalarType },
is: { type: FilterIs },
},
});

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