feat: conditional schema based on column map instead of column field (#1978)

* feat: wip conditional schema based on column map instead of column field

* feat: conditionalSchema columnMap and singular plural

* fix: remove uuid fix

* feat: add name and label (singular/plural) drop old tableColumnName
This commit is contained in:
Jérémy M
2023-10-12 18:28:27 +02:00
committed by GitHub
parent 8fbad7d3ba
commit 4e993316a6
44 changed files with 1577 additions and 311 deletions

View File

@ -1,6 +1,7 @@
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
export interface SchemaBuilderContext {
entityName: string;
tableName: string;
workspaceId: string;
fieldAliases: Record<string, string>;
fields: FieldMetadata[];
}

View File

@ -1,4 +1,4 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import {
GraphQLFieldConfigMap,
@ -9,9 +9,9 @@ import {
GraphQLObjectType,
GraphQLSchema,
} from 'graphql';
import upperFirst from 'lodash.upperfirst';
import { EntityResolverService } from 'src/tenant/entity-resolver/entity-resolver.service';
import { pascalCase } from 'src/utils/pascal-case';
import { ObjectMetadata } from 'src/metadata/object-metadata/object-metadata.entity';
import { generateEdgeType } from './utils/generate-edge-type.util';
@ -20,6 +20,7 @@ import { generateObjectType } from './utils/generate-object-type.util';
import { generateCreateInputType } from './utils/generate-create-input-type.util';
import { generateUpdateInputType } from './utils/generate-update-input-type.util';
import { SchemaBuilderContext } from './interfaces/schema-builder-context.interface';
import { cleanEntityName } from './utils/clean-entity-name.util';
@Injectable()
export class SchemaBuilderService {
@ -28,31 +29,25 @@ export class SchemaBuilderService {
constructor(private readonly entityResolverService: EntityResolverService) {}
private generateQueryFieldForEntity(
entityName: string,
entityName: {
singular: string;
plural: string;
},
tableName: string,
ObjectType: GraphQLObjectType,
objectDefinition: ObjectMetadata,
) {
const fieldAliases =
objectDefinition?.fields.reduce(
(acc, field) => ({
...acc,
[field.displayName]: field.targetColumnName,
}),
{},
) || {};
const schemaBuilderContext: SchemaBuilderContext = {
entityName,
tableName,
workspaceId: this.workspaceId,
fieldAliases,
fields: objectDefinition.fields,
};
const EdgeType = generateEdgeType(ObjectType);
const ConnectionType = generateConnectionType(EdgeType);
return {
[`findMany${pascalCase(entityName)}`]: {
[`${entityName.plural}`]: {
type: ConnectionType,
resolve: async (root, args, context, info) => {
return this.entityResolverService.findMany(
@ -61,7 +56,7 @@ export class SchemaBuilderService {
);
},
},
[`findOne${pascalCase(entityName)}`]: {
[`${entityName.singular}`]: {
type: ObjectType,
args: {
id: { type: new GraphQLNonNull(GraphQLID) },
@ -78,30 +73,24 @@ export class SchemaBuilderService {
}
private generateMutationFieldForEntity(
entityName: string,
entityName: {
singular: string;
plural: string;
},
tableName: string,
ObjectType: GraphQLObjectType,
CreateInputType: GraphQLInputObjectType,
UpdateInputType: GraphQLInputObjectType,
objectDefinition: ObjectMetadata,
) {
const fieldAliases =
objectDefinition?.fields.reduce(
(acc, field) => ({
...acc,
[field.displayName]: field.targetColumnName,
}),
{},
) || {};
const schemaBuilderContext: SchemaBuilderContext = {
entityName,
tableName,
workspaceId: this.workspaceId,
fieldAliases,
fields: objectDefinition.fields,
};
return {
[`createOne${pascalCase(entityName)}`]: {
[`createOne${upperFirst(entityName.singular)}`]: {
type: new GraphQLNonNull(ObjectType),
args: {
data: { type: new GraphQLNonNull(CreateInputType) },
@ -114,7 +103,7 @@ export class SchemaBuilderService {
);
},
},
[`createMany${pascalCase(entityName)}`]: {
[`createMany${upperFirst(entityName.singular)}`]: {
type: new GraphQLList(ObjectType),
args: {
data: {
@ -131,7 +120,7 @@ export class SchemaBuilderService {
);
},
},
[`updateOne${pascalCase(entityName)}`]: {
[`updateOne${upperFirst(entityName.singular)}`]: {
type: new GraphQLNonNull(ObjectType),
args: {
id: { type: new GraphQLNonNull(GraphQLID) },
@ -156,33 +145,29 @@ export class SchemaBuilderService {
const mutationFields: any = {};
for (const objectDefinition of objectMetadata) {
if (objectDefinition.fields.length === 0) {
// A graphql type must define one or more fields
continue;
}
const entityName = {
singular: cleanEntityName(objectDefinition.nameSingular),
plural: cleanEntityName(objectDefinition.namePlural),
};
const tableName = objectDefinition?.targetTableName ?? '';
const ObjectType = generateObjectType(
objectDefinition.displayName,
entityName.singular,
objectDefinition.fields,
);
const CreateInputType = generateCreateInputType(
objectDefinition.displayName,
entityName.singular,
objectDefinition.fields,
);
const UpdateInputType = generateUpdateInputType(
objectDefinition.displayName,
entityName.singular,
objectDefinition.fields,
);
if (!objectDefinition) {
throw new InternalServerErrorException('Object definition not found');
}
Object.assign(
queryFields,
this.generateQueryFieldForEntity(
objectDefinition.displayName,
entityName,
tableName,
ObjectType,
objectDefinition,
@ -192,7 +177,7 @@ export class SchemaBuilderService {
Object.assign(
mutationFields,
this.generateMutationFieldForEntity(
objectDefinition.displayName,
entityName,
tableName,
ObjectType,
CreateInputType,

View File

@ -0,0 +1,22 @@
import { cleanEntityName } from 'src/tenant/schema-builder/utils/clean-entity-name.util';
describe('cleanEntityName', () => {
test('should camelCase strings', () => {
expect(cleanEntityName('hello world')).toBe('helloWorld');
expect(cleanEntityName('my name is John')).toBe('myNameIsJohn');
});
test('should remove numbers at the beginning', () => {
expect(cleanEntityName('123hello')).toBe('hello');
expect(cleanEntityName('456hello world')).toBe('helloWorld');
});
test('should remove special characters', () => {
expect(cleanEntityName('hello$world')).toBe('helloWorld');
expect(cleanEntityName('some#special&chars')).toBe('someSpecialChars');
});
test('should handle empty strings', () => {
expect(cleanEntityName('')).toBe('');
});
});

View File

@ -0,0 +1,52 @@
import {
GraphQLList,
GraphQLNonNull,
GraphQLObjectType,
GraphQLString,
} from 'graphql';
import { PageInfoType } from 'src/tenant/schema-builder/utils/page-into-type.util';
import { generateConnectionType } from 'src/tenant/schema-builder/utils/generate-connection-type.util';
describe('generateConnectionType', () => {
// Create a mock EdgeType for testing
const mockEdgeType = new GraphQLObjectType({
name: 'MockEdge',
fields: {
node: { type: GraphQLString },
cursor: { type: GraphQLString },
},
});
// Generate a connection type using the mock
const MockConnectionType = generateConnectionType(mockEdgeType);
test('should generate a GraphQLObjectType', () => {
expect(MockConnectionType).toBeInstanceOf(GraphQLObjectType);
});
test('should generate a type with the correct name', () => {
expect(MockConnectionType.name).toBe('MockConnection');
});
test('should include the correct fields', () => {
const fields = MockConnectionType.getFields();
expect(fields).toHaveProperty('edges');
if (
fields.edges.type instanceof GraphQLList ||
fields.edges.type instanceof GraphQLNonNull
) {
expect(fields.edges.type.ofType).toBe(mockEdgeType);
} else {
fail('edges.type is not an instance of GraphQLList or GraphQLNonNull');
}
expect(fields).toHaveProperty('pageInfo');
if (fields.pageInfo.type instanceof GraphQLNonNull) {
expect(fields.pageInfo.type.ofType).toBe(PageInfoType);
} else {
fail('pageInfo.type is not an instance of GraphQLNonNull');
}
});
});

View File

@ -0,0 +1,56 @@
import {
GraphQLID,
GraphQLInputObjectType,
GraphQLInt,
GraphQLNonNull,
GraphQLString,
} from 'graphql';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
import { generateCreateInputType } from 'src/tenant/schema-builder/utils/generate-create-input-type.util';
describe('generateCreateInputType', () => {
test('should generate a GraphQLInputObjectType with correct name', () => {
const columns = [];
const name = 'testType';
const inputType = generateCreateInputType(name, columns);
expect(inputType).toBeInstanceOf(GraphQLInputObjectType);
expect(inputType.name).toBe('TestTypeCreateInput');
});
test('should include default id field', () => {
const columns = [];
const name = 'testType';
const inputType = generateCreateInputType(name, columns);
const fields = inputType.getFields();
expect(fields.id).toBeDefined();
expect(fields.id.type).toBe(GraphQLID);
});
test('should generate fields with correct types and descriptions', () => {
const columns = [
{
nameSingular: 'firstName',
type: 'text',
isNullable: false,
},
{
nameSingular: 'age',
type: 'number',
isNullable: true,
},
] as FieldMetadata[];
const name = 'testType';
const inputType = generateCreateInputType(name, columns);
const fields = inputType.getFields();
if (fields.firstName.type instanceof GraphQLNonNull) {
expect(fields.firstName.type.ofType).toBe(GraphQLString);
} else {
fail('firstName type is not an instance of GraphQLNonNull');
}
expect(fields.age.type).toBe(GraphQLInt);
});
});

View File

@ -0,0 +1,38 @@
import { GraphQLNonNull, GraphQLObjectType, GraphQLString } from 'graphql';
import { generateEdgeType } from 'src/tenant/schema-builder/utils/generate-edge-type.util';
describe('generateEdgeType', () => {
// Mock GraphQLObjectType for testing
const mockObjectType = new GraphQLObjectType({
name: 'MockItem',
fields: {
sampleField: { type: GraphQLString },
},
});
test('should generate a GraphQLObjectType', () => {
const edgeType = generateEdgeType(mockObjectType);
expect(edgeType).toBeInstanceOf(GraphQLObjectType);
});
test('should generate a type with the correct name', () => {
const edgeType = generateEdgeType(mockObjectType);
expect(edgeType.name).toBe('MockItemEdge');
});
test('should have a "node" field of the provided ObjectType', () => {
const edgeType = generateEdgeType(mockObjectType);
const fields = edgeType.getFields();
expect(fields.node.type).toBe(mockObjectType);
});
test('should have a "cursor" field of type GraphQLNonNull(GraphQLString)', () => {
const edgeType = generateEdgeType(mockObjectType);
const fields = edgeType.getFields();
expect(fields.cursor.type).toBeInstanceOf(GraphQLNonNull);
if (fields.cursor.type instanceof GraphQLNonNull) {
expect(fields.cursor.type.ofType).toBe(GraphQLString);
}
});
});

View File

@ -0,0 +1,72 @@
import {
GraphQLID,
GraphQLInt,
GraphQLNonNull,
GraphQLObjectType,
GraphQLString,
} from 'graphql';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
import { generateObjectType } from 'src/tenant/schema-builder/utils/generate-object-type.util';
describe('generateObjectType', () => {
test('should generate a GraphQLObjectType with correct name', () => {
const columns = [];
const name = 'testType';
const objectType = generateObjectType(name, columns);
expect(objectType).toBeInstanceOf(GraphQLObjectType);
expect(objectType.name).toBe('TestType');
});
test('should include default fields', () => {
const columns = [];
const name = 'testType';
const objectType = generateObjectType(name, columns);
const fields = objectType.getFields();
if (fields.id.type instanceof GraphQLNonNull) {
expect(fields.id.type.ofType).toBe(GraphQLID);
} else {
fail('id.type is not an instance of GraphQLNonNull');
}
if (fields.createdAt.type instanceof GraphQLNonNull) {
expect(fields.createdAt.type.ofType).toBe(GraphQLString);
} else {
fail('createdAt.type is not an instance of GraphQLNonNull');
}
if (fields.updatedAt.type instanceof GraphQLNonNull) {
expect(fields.updatedAt.type.ofType).toBe(GraphQLString);
} else {
fail('updatedAt.type is not an instance of GraphQLNonNull');
}
});
test('should generate fields based on provided columns', () => {
const columns = [
{
nameSingular: 'firstName',
type: 'text',
isNullable: false,
},
{
nameSingular: 'age',
type: 'number',
isNullable: true,
},
] as FieldMetadata[];
const name = 'testType';
const objectType = generateObjectType(name, columns);
const fields = objectType.getFields();
if (fields.firstName.type instanceof GraphQLNonNull) {
expect(fields.firstName.type.ofType).toBe(GraphQLString);
} else {
fail('firstName.type is not an instance of GraphQLNonNull');
}
expect(fields.age.type).toBe(GraphQLInt);
});
});

View File

@ -0,0 +1,51 @@
import {
GraphQLID,
GraphQLInputObjectType,
GraphQLInt,
GraphQLString,
} from 'graphql';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
import { generateUpdateInputType } from 'src/tenant/schema-builder/utils/generate-update-input-type.util';
describe('generateUpdateInputType', () => {
test('should generate a GraphQLInputObjectType with correct name', () => {
const columns = [];
const name = 'testType';
const inputType = generateUpdateInputType(name, columns);
expect(inputType).toBeInstanceOf(GraphQLInputObjectType);
expect(inputType.name).toBe('TestTypeUpdateInput');
});
test('should include default id field', () => {
const columns = [];
const name = 'testType';
const inputType = generateUpdateInputType(name, columns);
const fields = inputType.getFields();
expect(fields.id).toBeDefined();
expect(fields.id.type).toBe(GraphQLID);
});
test('should generate fields with correct types and descriptions', () => {
const columns = [
{
nameSingular: 'firstName',
type: 'text',
isNullable: true,
},
{
nameSingular: 'age',
type: 'number',
isNullable: true,
},
] as FieldMetadata[];
const name = 'testType';
const inputType = generateUpdateInputType(name, columns);
const fields = inputType.getFields();
expect(fields.firstName.type).toBe(GraphQLString);
expect(fields.age.type).toBe(GraphQLInt);
});
});

View File

@ -0,0 +1,77 @@
import {
GraphQLBoolean,
GraphQLEnumType,
GraphQLID,
GraphQLInt,
GraphQLString,
} from 'graphql';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
import { mapColumnTypeToGraphQLType } from 'src/tenant/schema-builder/utils/map-column-type-to-graphql-type.util';
describe('mapColumnTypeToGraphQLType', () => {
test('should map uuid to GraphQLID', () => {
const column = new FieldMetadata();
column.type = 'uuid';
expect(mapColumnTypeToGraphQLType(column)).toBe(GraphQLID);
});
test('should map text, phone, email, and date to GraphQLString', () => {
const types = ['text', 'phone', 'email', 'date'];
types.forEach((type) => {
const column = new FieldMetadata();
column.type = type;
expect(mapColumnTypeToGraphQLType(column)).toBe(GraphQLString);
});
});
test('should map boolean to GraphQLBoolean', () => {
const column = new FieldMetadata();
column.type = 'boolean';
expect(mapColumnTypeToGraphQLType(column)).toBe(GraphQLBoolean);
});
test('should map number to GraphQLInt', () => {
const column = new FieldMetadata();
column.type = 'number';
expect(mapColumnTypeToGraphQLType(column)).toBe(GraphQLInt);
});
test('should create a GraphQLEnumType for enum fields', () => {
const column = new FieldMetadata();
column.type = 'enum';
column.nameSingular = 'Status';
column.enums = ['ACTIVE', 'INACTIVE'];
const result = mapColumnTypeToGraphQLType(column);
if (result instanceof GraphQLEnumType) {
expect(result.name).toBe('StatusEnum');
const values = result.getValues().map((value) => value.value);
expect(values).toContain('ACTIVE');
expect(values).toContain('INACTIVE');
} else {
fail('Result is not an instance of GraphQLEnumType');
}
});
test('should map url to UrlObjectType or UrlInputType based on input flag', () => {
const column = new FieldMetadata();
column.type = 'url';
expect(mapColumnTypeToGraphQLType(column, false).name).toBe('Url');
expect(mapColumnTypeToGraphQLType(column, true).name).toBe('UrlInput');
});
test('should map money to MoneyObjectType or MoneyInputType based on input flag', () => {
const column = new FieldMetadata();
column.type = 'money';
expect(mapColumnTypeToGraphQLType(column, false).name).toBe('Money');
expect(mapColumnTypeToGraphQLType(column, true).name).toBe('MoneyInput');
});
test('should default to GraphQLString for unknown types', () => {
const column = new FieldMetadata();
column.type = 'unknown';
expect(mapColumnTypeToGraphQLType(column)).toBe(GraphQLString);
});
});

View File

@ -0,0 +1,17 @@
import { camelCase } from 'src/utils/camel-case';
export const cleanEntityName = (entityName: string) => {
// Remove all leading numbers
let camelCasedEntityName = entityName.replace(/^[0-9]+/, '');
// Trim the string
camelCasedEntityName = camelCasedEntityName.trim();
// Camel case the string
camelCasedEntityName = camelCase(camelCasedEntityName);
// Remove all special characters but keep alphabets and numbers
camelCasedEntityName = camelCasedEntityName.replace(/[^a-zA-Z0-9]/g, '');
return camelCasedEntityName;
};

View File

@ -1,4 +1,4 @@
import { GraphQLInputObjectType, GraphQLNonNull } from 'graphql';
import { GraphQLID, GraphQLInputObjectType, GraphQLNonNull } from 'graphql';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
import { pascalCase } from 'src/utils/pascal-case';
@ -15,14 +15,15 @@ export const generateCreateInputType = (
name: string,
columns: FieldMetadata[],
): GraphQLInputObjectType => {
const fields: Record<string, any> = {};
const fields: Record<string, any> = {
id: { type: GraphQLID },
};
columns.forEach((column) => {
const graphqlType = mapColumnTypeToGraphQLType(column);
const graphqlType = mapColumnTypeToGraphQLType(column, true);
fields[column.displayName] = {
fields[column.nameSingular] = {
type: !column.isNullable ? new GraphQLNonNull(graphqlType) : graphqlType,
description: column.targetColumnName,
};
});

View File

@ -33,9 +33,8 @@ export const generateObjectType = <TSource = any, TContext = any>(
columns.forEach((column) => {
const graphqlType = mapColumnTypeToGraphQLType(column);
fields[column.displayName] = {
fields[column.nameSingular] = {
type: !column.isNullable ? new GraphQLNonNull(graphqlType) : graphqlType,
description: column.targetColumnName,
};
});

View File

@ -1,4 +1,4 @@
import { GraphQLInputObjectType } from 'graphql';
import { GraphQLID, GraphQLInputObjectType } from 'graphql';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
import { pascalCase } from 'src/utils/pascal-case';
@ -15,14 +15,15 @@ export const generateUpdateInputType = (
name: string,
columns: FieldMetadata[],
): GraphQLInputObjectType => {
const fields: Record<string, any> = {};
const fields: Record<string, any> = {
id: { type: GraphQLID },
};
columns.forEach((column) => {
const graphqlType = mapColumnTypeToGraphQLType(column);
const graphqlType = mapColumnTypeToGraphQLType(column, true);
// No GraphQLNonNull wrapping here, so all fields are optional
fields[column.displayName] = {
fields[column.nameSingular] = {
type: graphqlType,
description: column.targetColumnName,
};
});

View File

@ -2,23 +2,61 @@ import {
GraphQLBoolean,
GraphQLEnumType,
GraphQLID,
GraphQLInputObjectType,
GraphQLInt,
GraphQLObjectType,
GraphQLString,
} from 'graphql';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
import { pascalCase } from 'src/utils/pascal-case';
const UrlObjectType = new GraphQLObjectType({
name: 'Url',
fields: {
text: { type: GraphQLString },
link: { type: GraphQLString },
},
});
const UrlInputType = new GraphQLInputObjectType({
name: 'UrlInput',
fields: {
text: { type: GraphQLString },
link: { type: GraphQLString },
},
});
const MoneyObjectType = new GraphQLObjectType({
name: 'Money',
fields: {
amount: { type: GraphQLInt },
currency: { type: GraphQLString },
},
});
const MoneyInputType = new GraphQLInputObjectType({
name: 'MoneyInput',
fields: {
amount: { type: GraphQLInt },
currency: { type: GraphQLString },
},
});
/**
* Map the column type from field-metadata to its corresponding GraphQL type.
* @param columnType Type of the column in the database.
*/
export const mapColumnTypeToGraphQLType = (column: FieldMetadata) => {
export const mapColumnTypeToGraphQLType = (
column: FieldMetadata,
input = false,
) => {
switch (column.type) {
case 'uuid':
return GraphQLID;
case 'text':
case 'url':
case 'phone':
case 'email':
case 'date':
return GraphQLString;
case 'boolean':
@ -27,9 +65,7 @@ export const mapColumnTypeToGraphQLType = (column: FieldMetadata) => {
return GraphQLInt;
case 'enum': {
if (column.enums && column.enums.length > 0) {
const enumName = `${pascalCase(column.objectId)}${pascalCase(
column.displayName,
)}Enum`;
const enumName = `${pascalCase(column.nameSingular)}Enum`;
return new GraphQLEnumType({
name: enumName,
@ -39,6 +75,12 @@ export const mapColumnTypeToGraphQLType = (column: FieldMetadata) => {
});
}
}
case 'url': {
return input ? UrlInputType : UrlObjectType;
}
case 'money': {
return input ? MoneyInputType : MoneyObjectType;
}
default:
return GraphQLString;
}