feat: conditional filtering & aggregation support & data ordering support (#2107)

* feat: wip

* feat: add filter on findOne

* fix: tests & small bug

* feat: add test and support aggregation

* feat: add order by support

* fix: fix comments

* fix: tests
This commit is contained in:
Jérémy M
2023-10-19 15:24:36 +02:00
committed by GitHub
parent 2b8a81a05c
commit 3e83cb6846
23 changed files with 555 additions and 156 deletions

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,19 @@
import { GraphQLInputObjectType, GraphQLList, GraphQLNonNull } from 'graphql';
import { DateTimeScalarType } from 'src/tenant/schema-builder/graphql-types/scalars/date-time.scalar';
import { FilterIsEnumType } from './filter-is-enum-filter.type';
export const DatetimeFilterType = new GraphQLInputObjectType({
name: 'DateTimeFilter',
fields: {
eq: { type: DateTimeScalarType },
gt: { type: DateTimeScalarType },
gte: { type: DateTimeScalarType },
in: { type: new GraphQLList(new GraphQLNonNull(DateTimeScalarType)) },
lt: { type: DateTimeScalarType },
lte: { type: DateTimeScalarType },
neq: { type: DateTimeScalarType },
is: { type: FilterIsEnumType },
},
});

View File

@ -1,19 +0,0 @@
import { GraphQLInputObjectType, GraphQLList, GraphQLNonNull } from 'graphql';
import { DatetimeScalarType } from 'src/tenant/schema-builder/graphql-types/scalars/datetime.scalar';
import { FilterIsEnumType } from './filter-is-enum-filter.type';
export const DatetimeFilterInputType = new GraphQLInputObjectType({
name: 'DatetimeFilter',
fields: {
eq: { type: DatetimeScalarType },
gt: { type: DatetimeScalarType },
gte: { type: DatetimeScalarType },
in: { type: new GraphQLList(new GraphQLNonNull(DatetimeScalarType)) },
lt: { type: DatetimeScalarType },
lte: { type: DatetimeScalarType },
neq: { type: DatetimeScalarType },
is: { type: FilterIsEnumType },
},
});

View File

@ -0,0 +1,24 @@
import { GraphQLScalarType, Kind } from 'graphql';
export const CursorScalarType = new GraphQLScalarType({
name: 'Cursor',
description: 'A custom scalar that represents a cursor for pagination',
serialize(value) {
if (typeof value !== 'string') {
throw new Error('Cursor must be a string');
}
return value;
},
parseValue(value) {
if (typeof value !== 'string') {
throw new Error('Cursor must be a string');
}
return value;
},
parseLiteral(ast) {
if (ast.kind !== Kind.STRING) {
throw new Error('Cursor must be a string');
}
return ast.value;
},
});

View File

@ -1,8 +1,8 @@
import { GraphQLScalarType } from 'graphql';
import { Kind } from 'graphql/language';
export const DatetimeScalarType = new GraphQLScalarType({
name: 'Datetime',
export const DateTimeScalarType = new GraphQLScalarType({
name: 'DateTime',
description: 'A custom scalar that represents a datetime in ISO format',
serialize(value: string): string {
const date = new Date(value);

View File

@ -0,0 +1,17 @@
import { BigFloatScalarType } from './big-float.scalar';
import { BigIntScalarType } from './big-int.scalar';
import { CursorScalarType } from './cursor.scalar';
import { DateScalarType } from './date.scalar';
import { DateTimeScalarType } from './date-time.scalar';
import { TimeScalarType } from './time.scalar';
import { UUIDScalarType } from './uuid.scalar';
export const scalars = [
BigFloatScalarType,
BigIntScalarType,
DateScalarType,
DateTimeScalarType,
TimeScalarType,
UUIDScalarType,
CursorScalarType,
];

View File

@ -4,6 +4,7 @@ import {
GraphQLFieldConfigMap,
GraphQLID,
GraphQLInputObjectType,
GraphQLInt,
GraphQLList,
GraphQLNonNull,
GraphQLObjectType,
@ -21,6 +22,10 @@ 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';
import { scalars } from './graphql-types/scalars';
import { CursorScalarType } from './graphql-types/scalars/cursor.scalar';
import { generateFilterInputType } from './utils/generate-filter-input-type.util';
import { generateOrderByInputType } from './utils/generate-order-by-input-type.util';
@Injectable()
export class SchemaBuilderService {
@ -45,12 +50,29 @@ export class SchemaBuilderService {
const EdgeType = generateEdgeType(ObjectType);
const ConnectionType = generateConnectionType(EdgeType);
const FilterInputType = generateFilterInputType(
entityName.singular,
objectDefinition.fields,
);
const OrderByInputType = generateOrderByInputType(
entityName.singular,
objectDefinition.fields,
);
return {
[`${entityName.plural}`]: {
type: ConnectionType,
args: {
first: { type: GraphQLInt },
last: { type: GraphQLInt },
before: { type: CursorScalarType },
after: { type: CursorScalarType },
filter: { type: FilterInputType },
orderBy: { type: OrderByInputType },
},
resolve: async (root, args, context, info) => {
return this.entityResolverService.findMany(
args,
schemaBuilderContext,
info,
);
@ -59,7 +81,7 @@ export class SchemaBuilderService {
[`${entityName.singular}`]: {
type: ObjectType,
args: {
id: { type: new GraphQLNonNull(GraphQLID) },
filter: { type: FilterInputType },
},
resolve: (root, args, context, info) => {
return this.entityResolverService.findOne(
@ -211,6 +233,7 @@ export class SchemaBuilderService {
return new GraphQLSchema({
query,
mutation,
types: [...scalars],
});
}
}

View File

@ -0,0 +1,53 @@
import { GraphQLList, GraphQLNonNull, GraphQLInputObjectType } from 'graphql';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
import { generateFilterInputType } from 'src/tenant/schema-builder/utils/generate-filter-input-type.util';
import { mapColumnTypeToFilterType } from 'src/tenant/schema-builder/utils/map-column-type-to-filter-type.util';
describe('generateFilterInputType', () => {
it('handles empty columns array', () => {
const FilterInputType = generateFilterInputType('EmptyTest', []);
expect(FilterInputType.name).toBe('EmptyTestFilterInput');
expect(FilterInputType.getFields()).toHaveProperty('id');
expect(FilterInputType.getFields()).toHaveProperty('createdAt');
expect(FilterInputType.getFields()).toHaveProperty('updatedAt');
expect(FilterInputType.getFields()).toHaveProperty('and');
expect(FilterInputType.getFields()).toHaveProperty('or');
expect(FilterInputType.getFields()).toHaveProperty('not');
});
it('handles various column types', () => {
const columns = [
{ name: 'stringField', type: 'text' },
{ name: 'intField', type: 'number' },
{ name: 'booleanField', type: 'boolean' },
] as FieldMetadata[];
const FilterInputType = generateFilterInputType('MultiTypeTest', columns);
columns.forEach((column) => {
const expectedType = mapColumnTypeToFilterType(column);
expect(FilterInputType.getFields()[column.name].type).toBe(expectedType);
});
});
it('handles nested logical fields', () => {
const FilterInputType = generateFilterInputType('NestedTest', []);
const andFieldType = FilterInputType.getFields().and.type;
const orFieldType = FilterInputType.getFields().or.type;
const notFieldType = FilterInputType.getFields().not.type;
expect(andFieldType).toBeInstanceOf(GraphQLList);
expect(orFieldType).toBeInstanceOf(GraphQLList);
if (notFieldType instanceof GraphQLNonNull) {
expect(notFieldType.ofType).toBe(FilterInputType);
} else {
expect(notFieldType).toBeInstanceOf(GraphQLInputObjectType);
}
});
});

View File

@ -8,6 +8,7 @@ import {
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
import { generateObjectType } from 'src/tenant/schema-builder/utils/generate-object-type.util';
import { DateTimeScalarType } from 'src/tenant/schema-builder/graphql-types/scalars/date-time.scalar';
describe('generateObjectType', () => {
test('should generate a GraphQLObjectType with correct name', () => {
@ -31,13 +32,13 @@ describe('generateObjectType', () => {
}
if (fields.createdAt.type instanceof GraphQLNonNull) {
expect(fields.createdAt.type.ofType).toBe(GraphQLString);
expect(fields.createdAt.type.ofType).toBe(DateTimeScalarType);
} else {
fail('createdAt.type is not an instance of GraphQLNonNull');
}
if (fields.updatedAt.type instanceof GraphQLNonNull) {
expect(fields.updatedAt.type.ofType).toBe(GraphQLString);
expect(fields.updatedAt.type.ofType).toBe(DateTimeScalarType);
} else {
fail('updatedAt.type is not an instance of GraphQLNonNull');
}

View File

@ -0,0 +1,26 @@
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
import { OrderByDirectionType } from 'src/tenant/schema-builder/graphql-types/enum/order-by-direction.type';
import { generateOrderByInputType } from 'src/tenant/schema-builder/utils/generate-order-by-input-type.util';
describe('generateOrderByInputType', () => {
it('includes default fields', () => {
const result = generateOrderByInputType('SampleType', []);
const fields = result.getFields();
expect(fields.id.type).toBe(OrderByDirectionType);
expect(fields.createdAt.type).toBe(OrderByDirectionType);
expect(fields.updatedAt.type).toBe(OrderByDirectionType);
});
it('adds fields from provided FieldMetadata columns', () => {
const testColumns = [
{ name: 'customField1' },
{ name: 'customField2' },
] as FieldMetadata[];
const result = generateOrderByInputType('SampleType', testColumns);
const fields = result.getFields();
expect(fields.customField1.type).toBe(OrderByDirectionType);
expect(fields.customField2.type).toBe(OrderByDirectionType);
});
});

View File

@ -0,0 +1,52 @@
import { GraphQLInputObjectType, GraphQLList, GraphQLNonNull } from 'graphql';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
import { pascalCase } from 'src/utils/pascal-case';
import { UUIDFilterType } from 'src/tenant/schema-builder/graphql-types/input/uuid-filter.type';
import { DatetimeFilterType } from 'src/tenant/schema-builder/graphql-types/input/date-time-filter.type';
import { mapColumnTypeToFilterType } from './map-column-type-to-filter-type.util';
const defaultFields = {
id: { type: UUIDFilterType },
createdAt: { type: DatetimeFilterType },
updatedAt: { type: DatetimeFilterType },
};
/**
* Generate a GraphQL filter input type with filters based on the columns from metadata.
* @param name Name for the GraphQL object.
* @param columns Array of FieldMetadata columns.
* @returns GraphQLInputObjectType
*/
export const generateFilterInputType = (
name: string,
columns: FieldMetadata[],
): GraphQLInputObjectType => {
const filterInputType = new GraphQLInputObjectType({
name: `${pascalCase(name)}FilterInput`,
fields: () => ({
...defaultFields,
...columns.reduce((fields, column) => {
const graphqlType = mapColumnTypeToFilterType(column);
fields[column.name] = {
type: graphqlType,
};
return fields;
}, {}),
and: {
type: new GraphQLList(new GraphQLNonNull(filterInputType)),
},
or: {
type: new GraphQLList(new GraphQLNonNull(filterInputType)),
},
not: {
type: filterInputType,
},
}),
});
return filterInputType;
};

View File

@ -1,19 +1,15 @@
import {
GraphQLID,
GraphQLNonNull,
GraphQLObjectType,
GraphQLString,
} from 'graphql';
import { GraphQLID, GraphQLNonNull, GraphQLObjectType } from 'graphql';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
import { pascalCase } from 'src/utils/pascal-case';
import { DateTimeScalarType } from 'src/tenant/schema-builder/graphql-types/scalars/date-time.scalar';
import { mapColumnTypeToGraphQLType } from './map-column-type-to-graphql-type.util';
const defaultFields = {
id: { type: new GraphQLNonNull(GraphQLID) },
createdAt: { type: new GraphQLNonNull(GraphQLString) },
updatedAt: { type: new GraphQLNonNull(GraphQLString) },
createdAt: { type: new GraphQLNonNull(DateTimeScalarType) },
updatedAt: { type: new GraphQLNonNull(DateTimeScalarType) },
};
/**

View File

@ -0,0 +1,37 @@
import { GraphQLInputObjectType } from 'graphql';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
import { pascalCase } from 'src/utils/pascal-case';
import { OrderByDirectionType } from 'src/tenant/schema-builder/graphql-types/enum/order-by-direction.type';
const defaultFields = {
id: { type: OrderByDirectionType },
createdAt: { type: OrderByDirectionType },
updatedAt: { type: OrderByDirectionType },
};
/**
* Generate a GraphQL order by input type with order by fields based on the columns from metadata.
* @param name Name for the GraphQL object.
* @param columns Array of FieldMetadata columns.
* @returns GraphQLInputObjectType
*/
export const generateOrderByInputType = (
name: string,
columns: FieldMetadata[],
): GraphQLInputObjectType => {
const fields = {
...defaultFields,
};
columns.forEach((column) => {
fields[column.name] = {
type: OrderByDirectionType,
};
});
return new GraphQLInputObjectType({
name: `${pascalCase(name)}OrderBy`,
fields,
});
};