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:
@ -12,7 +12,18 @@ import { PGGraphQLQueryRunner } from './pg-graphql/pg-graphql-query-runner.util'
|
||||
export class EntityResolverService {
|
||||
constructor(private readonly dataSourceService: DataSourceService) {}
|
||||
|
||||
async findMany(context: SchemaBuilderContext, info: GraphQLResolveInfo) {
|
||||
async findMany(
|
||||
args: {
|
||||
first?: number;
|
||||
last?: number;
|
||||
before?: string;
|
||||
after?: string;
|
||||
filter?: any;
|
||||
orderBy?: any;
|
||||
},
|
||||
context: SchemaBuilderContext,
|
||||
info: GraphQLResolveInfo,
|
||||
) {
|
||||
const runner = new PGGraphQLQueryRunner(this.dataSourceService, {
|
||||
tableName: context.tableName,
|
||||
workspaceId: context.workspaceId,
|
||||
@ -20,11 +31,11 @@ export class EntityResolverService {
|
||||
fields: context.fields,
|
||||
});
|
||||
|
||||
return runner.findMany();
|
||||
return runner.findMany(args);
|
||||
}
|
||||
|
||||
async findOne(
|
||||
args: { id: string },
|
||||
args: { filter?: any },
|
||||
context: SchemaBuilderContext,
|
||||
info: GraphQLResolveInfo,
|
||||
) {
|
||||
|
||||
@ -65,8 +65,9 @@ describe('PGGraphQLQueryBuilder', () => {
|
||||
queryBuilder = new PGGraphQLQueryBuilder(mockOptions);
|
||||
});
|
||||
|
||||
test('findMany generates correct query with complex and nested fields', () => {
|
||||
test('findMany generates correct query with no arguments', () => {
|
||||
const query = queryBuilder.findMany();
|
||||
|
||||
expect(normalizeWhitespace(query)).toBe(
|
||||
normalizeWhitespace(`
|
||||
query {
|
||||
@ -81,13 +82,19 @@ describe('PGGraphQLQueryBuilder', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('findOne generates correct query with complex and nested fields', () => {
|
||||
const args = { id: '1' };
|
||||
const query = queryBuilder.findOne(args);
|
||||
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: { id: { eq: "1" } }) {
|
||||
TestTableCollection(filter: { column_name: { eq: "Alice" }, column_age: { gt: 20 } }) {
|
||||
name: column_name
|
||||
age: column_age
|
||||
___complexField_subField1: column_subField1
|
||||
@ -98,6 +105,56 @@ describe('PGGraphQLQueryBuilder', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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: [
|
||||
@ -112,6 +169,7 @@ describe('PGGraphQLQueryBuilder', () => {
|
||||
],
|
||||
};
|
||||
const query = queryBuilder.createMany(args);
|
||||
|
||||
expect(normalizeWhitespace(query)).toBe(
|
||||
normalizeWhitespace(`
|
||||
mutation {
|
||||
@ -148,6 +206,7 @@ describe('PGGraphQLQueryBuilder', () => {
|
||||
},
|
||||
};
|
||||
const query = queryBuilder.updateOne(args);
|
||||
|
||||
expect(normalizeWhitespace(query)).toBe(
|
||||
normalizeWhitespace(`
|
||||
mutation {
|
||||
|
||||
@ -6,10 +6,17 @@ import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity
|
||||
import { stringifyWithoutKeyQuote } from 'src/tenant/entity-resolver/utils/stringify-without-key-quote.util';
|
||||
import { convertFieldsToGraphQL } from 'src/tenant/entity-resolver/utils/convert-fields-to-graphql.util';
|
||||
import { convertArguments } from 'src/tenant/entity-resolver/utils/convert-arguments.util';
|
||||
import { generateArgsInput } from 'src/tenant/entity-resolver/utils/generate-args-input.util';
|
||||
|
||||
type CommandArgs = {
|
||||
findMany: null;
|
||||
findOne: { id: string };
|
||||
findMany: {
|
||||
first?: number;
|
||||
last?: number;
|
||||
before?: string;
|
||||
after?: string;
|
||||
filter?: any;
|
||||
};
|
||||
findOne: { filter?: any };
|
||||
createMany: { data: any[] };
|
||||
updateOne: { id: string; data: any };
|
||||
};
|
||||
@ -34,41 +41,49 @@ export class PGGraphQLQueryBuilder {
|
||||
}
|
||||
|
||||
// Define command setters
|
||||
findMany() {
|
||||
findMany(args?: CommandArgs['findMany']) {
|
||||
const { tableName } = this.options;
|
||||
const fieldsString = this.getFieldsString();
|
||||
const convertedArgs = convertArguments(args, this.options.fields);
|
||||
const argsString = generateArgsInput(convertedArgs);
|
||||
|
||||
return `
|
||||
query {
|
||||
${tableName}Collection {
|
||||
${tableName}Collection${argsString ? `(${argsString})` : ''} {
|
||||
${fieldsString}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
findOne({ id }: CommandArgs['findOne']) {
|
||||
findOne(args: CommandArgs['findOne']) {
|
||||
const { tableName } = this.options;
|
||||
const fieldsString = this.getFieldsString();
|
||||
const convertedArgs = convertArguments(args, this.options.fields);
|
||||
const argsString = generateArgsInput(convertedArgs);
|
||||
|
||||
return `
|
||||
query {
|
||||
${tableName}Collection(filter: { id: { eq: "${id}" } }) {
|
||||
${fieldsString}
|
||||
${tableName}Collection${argsString ? `(${argsString})` : ''} {
|
||||
edges {
|
||||
node {
|
||||
${fieldsString}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
createMany({ data }: CommandArgs['createMany']) {
|
||||
createMany(initialArgs: CommandArgs['createMany']) {
|
||||
const { tableName } = this.options;
|
||||
const fieldsString = this.getFieldsString();
|
||||
const args = convertArguments(data, this.options.fields);
|
||||
const args = convertArguments(initialArgs, this.options.fields);
|
||||
|
||||
return `
|
||||
mutation {
|
||||
insertInto${tableName}Collection(objects: ${stringifyWithoutKeyQuote(
|
||||
args.map((datum) => ({
|
||||
args.data.map((datum) => ({
|
||||
id: uuidv4(),
|
||||
...datum,
|
||||
})),
|
||||
@ -82,16 +97,16 @@ export class PGGraphQLQueryBuilder {
|
||||
`;
|
||||
}
|
||||
|
||||
updateOne({ id, data }: CommandArgs['updateOne']) {
|
||||
updateOne(initialArgs: CommandArgs['updateOne']) {
|
||||
const { tableName } = this.options;
|
||||
const fieldsString = this.getFieldsString();
|
||||
const args = convertArguments(data, this.options.fields);
|
||||
const args = convertArguments(initialArgs, this.options.fields);
|
||||
|
||||
return `
|
||||
mutation {
|
||||
update${tableName}Collection(set: ${stringifyWithoutKeyQuote(
|
||||
args,
|
||||
)}, filter: { id: { eq: "${id}" } }) {
|
||||
args.data,
|
||||
)}, filter: { id: { eq: "${args.id}" } }) {
|
||||
affectedCount
|
||||
records {
|
||||
${fieldsString}
|
||||
|
||||
@ -58,18 +58,30 @@ export class PGGraphQLQueryRunner {
|
||||
return parseResult(result);
|
||||
}
|
||||
|
||||
async findMany(): Promise<any[]> {
|
||||
const query = this.queryBuilder.findMany();
|
||||
async findMany(args: {
|
||||
first?: number;
|
||||
last?: number;
|
||||
before?: string;
|
||||
after?: string;
|
||||
filter?: any;
|
||||
orderBy?: any;
|
||||
}): Promise<any[]> {
|
||||
const query = this.queryBuilder.findMany(args);
|
||||
const result = await this.execute(query, this.options.workspaceId);
|
||||
|
||||
return this.parseResult(result, '');
|
||||
}
|
||||
|
||||
async findOne(args: { id: string }): Promise<any> {
|
||||
async findOne(args: { filter?: any }): Promise<any> {
|
||||
if (!args.filter || Object.keys(args.filter).length === 0) {
|
||||
throw new BadRequestException('Missing filter argument');
|
||||
}
|
||||
|
||||
const query = this.queryBuilder.findOne(args);
|
||||
const result = await this.execute(query, this.options.workspaceId);
|
||||
const parsedResult = this.parseResult(result, '');
|
||||
|
||||
return this.parseResult(result, '');
|
||||
return parsedResult?.edges?.[0]?.node;
|
||||
}
|
||||
|
||||
async createMany(args: { data: any[] }): Promise<any[]> {
|
||||
|
||||
@ -66,4 +66,25 @@ describe('convertArguments', () => {
|
||||
const expected = { column_1randomFirstNameKey: 'John', lastName: 'Doe' };
|
||||
expect(convertArguments(args, fields)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('should handle deeper nested object arguments', () => {
|
||||
const args = {
|
||||
user: {
|
||||
details: {
|
||||
firstName: 'John',
|
||||
website: { link: 'https://www.example.com', text: 'example' },
|
||||
},
|
||||
},
|
||||
};
|
||||
const expected = {
|
||||
user: {
|
||||
details: {
|
||||
column_1randomFirstNameKey: 'John',
|
||||
column_randomLinkKey: 'https://www.example.com',
|
||||
column_randomTex7Key: 'example',
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(convertArguments(args, fields)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,61 @@
|
||||
import { generateArgsInput } from 'src/tenant/entity-resolver/utils/generate-args-input.util';
|
||||
|
||||
const normalizeWhitespace = (str) => str.replace(/\s+/g, '');
|
||||
|
||||
describe('generateArgsInput', () => {
|
||||
it('should handle string inputs', () => {
|
||||
const args = { someKey: 'someValue' };
|
||||
|
||||
expect(normalizeWhitespace(generateArgsInput(args))).toBe(
|
||||
normalizeWhitespace('someKey: "someValue"'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle number inputs', () => {
|
||||
const args = { someKey: 123 };
|
||||
|
||||
expect(normalizeWhitespace(generateArgsInput(args))).toBe(
|
||||
normalizeWhitespace('someKey: 123'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle boolean inputs', () => {
|
||||
const args = { someKey: true };
|
||||
|
||||
expect(normalizeWhitespace(generateArgsInput(args))).toBe(
|
||||
normalizeWhitespace('someKey: true'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip undefined values', () => {
|
||||
const args = { definedKey: 'value', undefinedKey: undefined };
|
||||
|
||||
expect(normalizeWhitespace(generateArgsInput(args))).toBe(
|
||||
normalizeWhitespace('definedKey: "value"'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle object inputs', () => {
|
||||
const args = { someKey: { nestedKey: 'nestedValue' } };
|
||||
|
||||
expect(normalizeWhitespace(generateArgsInput(args))).toBe(
|
||||
normalizeWhitespace('someKey: {nestedKey: "nestedValue"}'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle null inputs', () => {
|
||||
const args = { someKey: null };
|
||||
|
||||
expect(normalizeWhitespace(generateArgsInput(args))).toBe(
|
||||
normalizeWhitespace('someKey: null'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove trailing commas', () => {
|
||||
const args = { firstKey: 'firstValue', secondKey: 'secondValue' };
|
||||
|
||||
expect(normalizeWhitespace(generateArgsInput(args))).toBe(
|
||||
normalizeWhitespace('firstKey: "firstValue", secondKey: "secondValue"'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -1,55 +0,0 @@
|
||||
import {
|
||||
FieldMetadata,
|
||||
FieldMetadataTargetColumnMap,
|
||||
} from 'src/metadata/field-metadata/field-metadata.entity';
|
||||
import { getFieldAliases } from 'src/tenant/entity-resolver/utils/get-fields-aliases.util';
|
||||
|
||||
describe('getFieldAliases', () => {
|
||||
let fields: FieldMetadata[];
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup sample field metadata
|
||||
fields = [
|
||||
{
|
||||
name: 'singleValueField',
|
||||
targetColumnMap: {
|
||||
value: 'column_singleValue',
|
||||
} as FieldMetadataTargetColumnMap,
|
||||
},
|
||||
{
|
||||
name: 'multipleValuesField',
|
||||
targetColumnMap: {
|
||||
link: 'column_value1',
|
||||
text: 'column_value2',
|
||||
} as FieldMetadataTargetColumnMap,
|
||||
},
|
||||
] as FieldMetadata[];
|
||||
});
|
||||
|
||||
test('should return correct aliases for fields with a single value in targetColumnMap', () => {
|
||||
const aliases = getFieldAliases(fields);
|
||||
expect(aliases).toHaveProperty('singleValueField', 'column_singleValue');
|
||||
});
|
||||
|
||||
test('should return correct aliases for fields with multiple values in targetColumnMap', () => {
|
||||
const aliases = getFieldAliases(fields);
|
||||
expect(aliases).toHaveProperty('column_value1', 'column_value1');
|
||||
});
|
||||
|
||||
test('should handle empty fields array', () => {
|
||||
const aliases = getFieldAliases([]);
|
||||
expect(aliases).toEqual({});
|
||||
});
|
||||
|
||||
test('should not create aliases for fields without targetColumnMap values', () => {
|
||||
const fieldsWithEmptyMap = [
|
||||
...fields,
|
||||
{
|
||||
name: 'emptyField',
|
||||
targetColumnMap: {} as FieldMetadataTargetColumnMap,
|
||||
},
|
||||
] as FieldMetadata[];
|
||||
const aliases = getFieldAliases(fieldsWithEmptyMap);
|
||||
expect(aliases).not.toHaveProperty('emptyField');
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,3 @@
|
||||
import isEmpty from 'lodash.isempty';
|
||||
|
||||
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
|
||||
|
||||
export const convertArguments = (args: any, fields: FieldMetadata[]): any => {
|
||||
@ -7,31 +5,46 @@ export const convertArguments = (args: any, fields: FieldMetadata[]): any => {
|
||||
fields.map((metadata) => [metadata.name, metadata]),
|
||||
);
|
||||
|
||||
if (Array.isArray(args)) {
|
||||
return args.map((arg) => convertArguments(arg, fields));
|
||||
}
|
||||
const processObject = (obj: any): any => {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
const newArgs = {};
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => processObject(item));
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
if (fieldsMap.has(key)) {
|
||||
const fieldMetadata = fieldsMap.get(key)!;
|
||||
const newObj = {};
|
||||
|
||||
if (typeof value === 'object' && value !== null && !isEmpty(value)) {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const fieldMetadata = fieldsMap.get(key);
|
||||
|
||||
if (
|
||||
fieldMetadata &&
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
Object.values(fieldMetadata.targetColumnMap).length > 1
|
||||
) {
|
||||
for (const [subKey, subValue] of Object.entries(value)) {
|
||||
if (fieldMetadata.targetColumnMap[subKey]) {
|
||||
newArgs[fieldMetadata.targetColumnMap[subKey]] = subValue;
|
||||
const mappedKey = fieldMetadata.targetColumnMap[subKey];
|
||||
|
||||
if (mappedKey) {
|
||||
newObj[mappedKey] = subValue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (fieldMetadata.targetColumnMap.value) {
|
||||
newArgs[fieldMetadata.targetColumnMap.value] = value;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
newArgs[key] = value;
|
||||
}
|
||||
}
|
||||
} else if (fieldMetadata) {
|
||||
const mappedKey = fieldMetadata.targetColumnMap.value;
|
||||
|
||||
return newArgs;
|
||||
if (mappedKey) {
|
||||
newObj[mappedKey] = value;
|
||||
}
|
||||
} else {
|
||||
newObj[key] = processObject(value);
|
||||
}
|
||||
}
|
||||
|
||||
return newObj;
|
||||
};
|
||||
|
||||
return processObject(args);
|
||||
};
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
import { stringifyWithoutKeyQuote } from './stringify-without-key-quote.util';
|
||||
|
||||
export const generateArgsInput = (args: any) => {
|
||||
let argsString = '';
|
||||
|
||||
for (const key in args) {
|
||||
// Check if the value is not undefined
|
||||
if (args[key] === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof args[key] === 'string') {
|
||||
// If it's a string, add quotes
|
||||
argsString += `${key}: "${args[key]}", `;
|
||||
} else if (typeof args[key] === 'object' && args[key] !== null) {
|
||||
// If it's an object (and not null), stringify it
|
||||
argsString += `${key}: ${stringifyWithoutKeyQuote(args[key])}, `;
|
||||
} else {
|
||||
// For other types (number, boolean), add as is
|
||||
argsString += `${key}: ${args[key]}, `;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove trailing comma and space, if present
|
||||
if (argsString.endsWith(', ')) {
|
||||
argsString = argsString.slice(0, -2);
|
||||
}
|
||||
|
||||
return argsString;
|
||||
};
|
||||
@ -1,21 +0,0 @@
|
||||
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
|
||||
|
||||
export const getFieldAliases = (fields: FieldMetadata[]) => {
|
||||
const fieldAliases = fields.reduce((acc, column) => {
|
||||
const values = Object.values(column.targetColumnMap);
|
||||
|
||||
if (values.length === 1) {
|
||||
return {
|
||||
...acc,
|
||||
[column.name]: values[0],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...acc,
|
||||
[values[0]]: values[0],
|
||||
};
|
||||
}
|
||||
}, {});
|
||||
|
||||
return fieldAliases;
|
||||
};
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -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 },
|
||||
},
|
||||
});
|
||||
@ -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 },
|
||||
},
|
||||
});
|
||||
@ -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;
|
||||
},
|
||||
});
|
||||
@ -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);
|
||||
@ -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,
|
||||
];
|
||||
@ -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],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
};
|
||||
@ -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) },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user