Migrate to a monorepo structure (#2909)

This commit is contained in:
Charles Bochet
2023-12-10 18:10:54 +01:00
committed by GitHub
parent a70a9281eb
commit 5bdca9de6c
2304 changed files with 37152 additions and 25869 deletions

View File

@ -0,0 +1,237 @@
// import { GraphQLResolveInfo } from 'graphql';
// import { FieldMetadataTargetColumnMap } from 'src/metadata/field-metadata/interfaces/field-metadata-target-column-map.interface';
// import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
// import {
// PGGraphQLQueryBuilder,
// PGGraphQLQueryBuilderOptions,
// } from 'src/tenant/resolver-builder/pg-graphql/pg-graphql-query-builder';
// const testUUID = '123e4567-e89b-12d3-a456-426614174001';
// const normalizeWhitespace = (str) => str.replace(/\s+/g, '');
// // Mocking dependencies
// jest.mock('uuid', () => ({
// v4: jest.fn(() => testUUID),
// }));
// jest.mock('graphql-fields', () =>
// jest.fn(() => ({
// name: true,
// age: true,
// complexField: {
// subField1: true,
// subField2: true,
// },
// })),
// );
// describe('PGGraphQLQueryBuilder', () => {
// let queryBuilder;
// let mockOptions: PGGraphQLQueryBuilderOptions;
// beforeEach(() => {
// const fieldMetadataCollection = [
// {
// name: 'name',
// targetColumnMap: {
// value: 'column_name',
// } as FieldMetadataTargetColumnMap,
// },
// {
// name: 'age',
// targetColumnMap: {
// value: 'column_age',
// } as FieldMetadataTargetColumnMap,
// },
// {
// name: 'complexField',
// targetColumnMap: {
// subField1: 'column_subField1',
// subField2: 'column_subField2',
// } as FieldMetadataTargetColumnMap,
// },
// ] as FieldMetadata[];
// mockOptions = {
// targetTableName: 'TestTable',
// info: {} as GraphQLResolveInfo,
// fieldMetadataCollection,
// };
// queryBuilder = new PGGraphQLQueryBuilder(mockOptions);
// });
// test('findMany generates correct query with no arguments', () => {
// const query = queryBuilder.findMany();
// expect(normalizeWhitespace(query)).toBe(
// normalizeWhitespace(`
// query {
// TestTableCollection {
// name: column_name
// age: column_age
// ___complexField_subField1: column_subField1
// ___complexField_subField2: column_subField2
// }
// }
// `),
// );
// });
// test('findMany generates correct query with filter parameters', () => {
// const args = {
// filter: {
// name: { eq: 'Alice' },
// age: { gt: 20 },
// },
// };
// const query = queryBuilder.findMany(args);
// expect(normalizeWhitespace(query)).toBe(
// normalizeWhitespace(`
// query {
// TestTableCollection(filter: { column_name: { eq: "Alice" }, column_age: { gt: 20 } }) {
// name: column_name
// age: column_age
// ___complexField_subField1: column_subField1
// ___complexField_subField2: column_subField2
// }
// }
// `),
// );
// });
// test('findMany generates correct query with combined pagination parameters', () => {
// const args = {
// first: 5,
// after: 'someCursor',
// before: 'anotherCursor',
// last: 3,
// };
// const query = queryBuilder.findMany(args);
// expect(normalizeWhitespace(query)).toBe(
// normalizeWhitespace(`
// query {
// TestTableCollection(
// first: 5,
// after: "someCursor",
// before: "anotherCursor",
// last: 3
// ) {
// name: column_name
// age: column_age
// ___complexField_subField1: column_subField1
// ___complexField_subField2: column_subField2
// }
// }
// `),
// );
// });
// test('findOne generates correct query with ID filter', () => {
// const args = { filter: { id: { eq: testUUID } } };
// const query = queryBuilder.findOne(args);
// expect(normalizeWhitespace(query)).toBe(
// normalizeWhitespace(`
// query {
// TestTableCollection(filter: { id: { eq: "${testUUID}" } }) {
// edges {
// node {
// name: column_name
// age: column_age
// ___complexField_subField1: column_subField1
// ___complexField_subField2: column_subField2
// }
// }
// }
// }
// `),
// );
// });
// test('createMany generates correct mutation with complex and nested fields', () => {
// const args = {
// data: [
// {
// name: 'Alice',
// age: 30,
// complexField: {
// subField1: 'data1',
// subField2: 'data2',
// },
// },
// ],
// };
// const query = queryBuilder.createMany(args);
// expect(normalizeWhitespace(query)).toBe(
// normalizeWhitespace(`
// mutation {
// insertIntoTestTableCollection(objects: [{
// id: "${testUUID}",
// column_name: "Alice",
// column_age: 30,
// column_subField1: "data1",
// column_subField2: "data2"
// }]) {
// affectedCount
// records {
// name: column_name
// age: column_age
// ___complexField_subField1: column_subField1
// ___complexField_subField2: column_subField2
// }
// }
// }
// `),
// );
// });
// test('updateOne generates correct mutation with complex and nested fields', () => {
// const args = {
// id: '1',
// data: {
// name: 'Bob',
// age: 40,
// complexField: {
// subField1: 'newData1',
// subField2: 'newData2',
// },
// },
// };
// const query = queryBuilder.updateOne(args);
// expect(normalizeWhitespace(query)).toBe(
// normalizeWhitespace(`
// mutation {
// updateTestTableCollection(
// set: {
// column_name: "Bob",
// column_age: 40,
// column_subField1: "newData1",
// column_subField2: "newData2"
// },
// filter: { id: { eq: "1" } }
// ) {
// affectedCount
// records {
// name: column_name
// age: column_age
// ___complexField_subField1: column_subField1
// ___complexField_subField2: column_subField2
// }
// }
// }
// `),
// );
// });
// });
it('should pass', () => {
expect(true).toBe(true);
});

View File

@ -0,0 +1,69 @@
import { Injectable } from '@nestjs/common';
import { FieldMetadataInterface } from 'src/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,56 @@
import { Injectable } from '@nestjs/common';
import { FieldMetadataInterface } from 'src/metadata/field-metadata/interfaces/field-metadata.interface';
import { stringifyWithoutKeyQuote } from 'src/workspace/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}: ${stringifyWithoutKeyQuote(
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;
}
}

View File

@ -0,0 +1,54 @@
import { Injectable, Logger } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import { WorkspaceQueryBuilderOptions } from 'src/workspace/workspace-query-builder/interfaces/workspace-query-builder-options.interface';
import { Record as IRecord } from 'src/workspace/workspace-query-builder/interfaces/record.interface';
import { CreateManyResolverArgs } from 'src/workspace/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { stringifyWithoutKeyQuote } from 'src/workspace/workspace-query-builder/utils/stringify-without-key-quote.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,
);
const computedArgs = this.argsAliasFactory.create(
args,
options.fieldMetadataCollection,
);
return `
mutation {
insertInto${
options.targetTableName
}Collection(objects: ${stringifyWithoutKeyQuote(
computedArgs.data.map((datum) => ({
id: uuidv4(),
...datum,
})),
)}) {
affectedCount
records {
${fieldsString}
}
}
}
`;
}
}

View File

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

View File

@ -0,0 +1,34 @@
import { Injectable, Logger } from '@nestjs/common';
import { WorkspaceQueryBuilderOptions } from 'src/workspace/workspace-query-builder/interfaces/workspace-query-builder-options.interface';
import { DeleteOneResolverArgs } from 'src/workspace/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
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,
);
return `
mutation {
deleteFrom${options.targetTableName}Collection(filter: { id: { eq: "${args.id}" } }) {
affectedCount
records {
${fieldsString}
}
}
}
`;
}
}

View File

@ -0,0 +1,27 @@
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 { FieldAliasFacotry } 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';
export const workspaceQueryBuilderFactories = [
ArgsAliasFactory,
ArgsStringFactory,
RelationFieldAliasFactory,
CreateManyQueryFactory,
DeleteOneQueryFactory,
FieldAliasFacotry,
FieldsStringFactory,
FindManyQueryFactory,
FindOneQueryFactory,
UpdateOneQueryFactory,
UpdateManyQueryFactory,
DeleteManyQueryFactory,
];

View File

@ -0,0 +1,30 @@
import { Injectable, Logger } from '@nestjs/common';
import { FieldMetadataInterface } from 'src/metadata/field-metadata/interfaces/field-metadata.interface';
@Injectable()
export class FieldAliasFacotry {
private readonly logger = new Logger(FieldAliasFacotry.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,98 @@
import { Injectable, Logger } from '@nestjs/common';
import { GraphQLResolveInfo } from 'graphql';
import graphqlFields from 'graphql-fields';
import isEmpty from 'lodash.isempty';
import { FieldMetadataInterface } from 'src/metadata/field-metadata/interfaces/field-metadata.interface';
import { isRelationFieldMetadataType } from 'src/workspace/utils/is-relation-field-metadata-type.util';
import { FieldAliasFacotry } 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: FieldAliasFacotry,
private readonly relationFieldAliasFactory: RelationFieldAliasFactory,
) {}
create(
info: GraphQLResolveInfo,
fieldMetadataCollection: FieldMetadataInterface[],
): Promise<string> {
// @ts-expect-error Todo: Fix typing error
const selectedFields: Record<string, any> = graphqlFields(info);
return this.createFieldsStringRecursive(
info,
selectedFields,
fieldMetadataCollection,
);
}
async createFieldsStringRecursive(
info: GraphQLResolveInfo,
selectedFields: Record<string, any>,
fieldMetadataCollection: FieldMetadataInterface[],
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,
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,
accumulator,
);
accumulator += `}\n`;
} else {
accumulator += `${fieldAlias}\n`;
}
}
return accumulator;
}
}

View File

@ -0,0 +1,48 @@
import { Injectable, Logger } from '@nestjs/common';
import { WorkspaceQueryBuilderOptions } from 'src/workspace/workspace-query-builder/interfaces/workspace-query-builder-options.interface';
import {
RecordFilter,
RecordOrderBy,
} from 'src/workspace/workspace-query-builder/interfaces/record.interface';
import { FindManyResolverArgs } from 'src/workspace/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
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,
);
const argsString = this.argsStringFactory.create(
args,
options.fieldMetadataCollection,
);
return `
query {
${options.targetTableName}Collection${
argsString ? `(${argsString})` : ''
} {
${fieldsString}
}
}
`;
}
}

View File

@ -0,0 +1,46 @@
import { Injectable, Logger } from '@nestjs/common';
import { WorkspaceQueryBuilderOptions } from 'src/workspace/workspace-query-builder/interfaces/workspace-query-builder-options.interface';
import { RecordFilter } from 'src/workspace/workspace-query-builder/interfaces/record.interface';
import { FindOneResolverArgs } from 'src/workspace/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
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,
);
const argsString = this.argsStringFactory.create(
args,
options.fieldMetadataCollection,
);
return `
query {
${options.targetTableName}Collection${
argsString ? `(${argsString})` : ''
} {
edges {
node {
${fieldsString}
}
}
}
}
`;
}
}

View File

@ -0,0 +1,140 @@
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { GraphQLResolveInfo } from 'graphql';
import { FieldMetadataInterface } from 'src/metadata/field-metadata/interfaces/field-metadata.interface';
import { isRelationFieldMetadataType } from 'src/workspace/utils/is-relation-field-metadata-type.util';
import { RelationMetadataType } from 'src/metadata/relation-metadata/relation-metadata.entity';
import {
deduceRelationDirection,
RelationDirection,
} from 'src/workspace/utils/deduce-relation-direction.util';
import { getFieldArgumentsByKey } from 'src/workspace/workspace-query-builder/utils/get-field-arguments-by-key.util';
import { ObjectMetadataService } from 'src/metadata/object-metadata/object-metadata.service';
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,
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, info);
}
private async createRelationAlias(
fieldKey: string,
fieldValue: any,
fieldMetadata: FieldMetadataInterface,
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.objectMetadataId,
relationMetadata,
);
// Retrieve the referenced object metadata based on the relation direction
// Mandatory to handle n+n relations
const referencedObjectMetadata =
await this.objectMetadataService.findOneWithinWorkspace(
fieldMetadata.workspaceId,
{
where: {
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 ?? [],
);
return `
${fieldKey}: ${referencedObjectMetadata.targetTableName}Collection${
argsString ? `(${argsString})` : ''
} {
${fieldsString}
}
`;
}
let relationAlias = fieldMetadata.isCustom
? `${fieldKey}: ${fieldMetadata.targetColumnMap.value}`
: fieldKey;
// For one to one relations, pg_graphql use the targetTableName 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}: ${referencedObjectMetadata.targetTableName}`;
}
const fieldsString =
await this.fieldsStringFactory.createFieldsStringRecursive(
info,
fieldValue,
referencedObjectMetadata.fields ?? [],
);
// Otherwise it means it's a relation destination is of kind ONE
return `
${relationAlias} {
${fieldsString}
}
`;
}
}

View File

@ -0,0 +1,51 @@
import { Injectable } from '@nestjs/common';
import {
Record as IRecord,
RecordFilter,
} from 'src/workspace/workspace-query-builder/interfaces/record.interface';
import { WorkspaceQueryBuilderOptions } from 'src/workspace/workspace-query-builder/interfaces/workspace-query-builder-options.interface';
import { UpdateManyResolverArgs } from 'src/workspace/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { stringifyWithoutKeyQuote } from 'src/workspace/workspace-query-builder/utils/stringify-without-key-quote.util';
import { FieldsStringFactory } from 'src/workspace/workspace-query-builder/factories/fields-string.factory';
import { ArgsAliasFactory } from 'src/workspace/workspace-query-builder/factories/args-alias.factory';
@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: WorkspaceQueryBuilderOptions,
) {
const fieldsString = await this.fieldsStringFactory.create(
options.info,
options.fieldMetadataCollection,
);
const computedArgs = this.argsAliasFactory.create(
args,
options.fieldMetadataCollection,
);
return `
mutation {
update${options.targetTableName}Collection(
set: ${stringifyWithoutKeyQuote(computedArgs.data)},
filter: ${stringifyWithoutKeyQuote(args.filter)},
) {
affectedCount
records {
${fieldsString}
}
}
}`;
}
}

View File

@ -0,0 +1,49 @@
import { Injectable, Logger } from '@nestjs/common';
import { WorkspaceQueryBuilderOptions } from 'src/workspace/workspace-query-builder/interfaces/workspace-query-builder-options.interface';
import { Record as IRecord } from 'src/workspace/workspace-query-builder/interfaces/record.interface';
import { UpdateOneResolverArgs } from 'src/workspace/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { stringifyWithoutKeyQuote } from 'src/workspace/workspace-query-builder/utils/stringify-without-key-quote.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,
);
const computedArgs = this.argsAliasFactory.create(
args,
options.fieldMetadataCollection,
);
return `
mutation {
update${
options.targetTableName
}Collection(set: ${stringifyWithoutKeyQuote(
computedArgs.data,
)}, filter: { id: { eq: "${computedArgs.id}" } }) {
affectedCount
records {
${fieldsString}
}
}
}
`;
}
}

View File

@ -0,0 +1,21 @@
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;
};

View File

@ -0,0 +1,9 @@
import { GraphQLResolveInfo } from 'graphql';
import { FieldMetadataInterface } from 'src/metadata/field-metadata/interfaces/field-metadata.interface';
export interface WorkspaceQueryBuilderOptions {
targetTableName: string;
info: GraphQLResolveInfo;
fieldMetadataCollection: FieldMetadataInterface[];
}

View File

@ -0,0 +1,58 @@
import { stringifyWithoutKeyQuote } from 'src/workspace/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,95 @@
import { Injectable, Logger } from '@nestjs/common';
import { WorkspaceQueryBuilderOptions } from 'src/workspace/workspace-query-builder/interfaces/workspace-query-builder-options.interface';
import {
Record as IRecord,
RecordFilter,
RecordOrderBy,
} from 'src/workspace/workspace-query-builder/interfaces/record.interface';
import {
FindManyResolverArgs,
FindOneResolverArgs,
CreateManyResolverArgs,
UpdateOneResolverArgs,
DeleteOneResolverArgs,
UpdateManyResolverArgs,
DeleteManyResolverArgs,
} from 'src/workspace/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 } from './factories/update-many-query.factory';
import { DeleteManyQueryFactory } from './factories/delete-many-query.factory';
@Injectable()
export class WorkspaceQueryBuilderFactory {
private readonly logger = new Logger(WorkspaceQueryBuilderFactory.name);
constructor(
private readonly findManyQueryFactory: FindManyQueryFactory,
private readonly findOneQueryFactory: FindOneQueryFactory,
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);
}
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: WorkspaceQueryBuilderOptions,
): Promise<string> {
return this.updateManyQueryFactory.create(args, options);
}
deleteMany<Filter extends RecordFilter = RecordFilter>(
args: DeleteManyResolverArgs<Filter>,
options: WorkspaceQueryBuilderOptions,
): Promise<string> {
return this.deleteManyQueryFactory.create(args, options);
}
}

View File

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