diff --git a/server/src/tenant/entity-resolver/entity-resolver.service.ts b/server/src/tenant/entity-resolver/entity-resolver.service.ts index 37ee902d3..9d78144d3 100644 --- a/server/src/tenant/entity-resolver/entity-resolver.service.ts +++ b/server/src/tenant/entity-resolver/entity-resolver.service.ts @@ -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, ) { diff --git a/server/src/tenant/entity-resolver/pg-graphql/__tests__/pg-graphql-query-builder.spec.ts b/server/src/tenant/entity-resolver/pg-graphql/__tests__/pg-graphql-query-builder.spec.ts index e2a64f1a0..60ef4e8a8 100644 --- a/server/src/tenant/entity-resolver/pg-graphql/__tests__/pg-graphql-query-builder.spec.ts +++ b/server/src/tenant/entity-resolver/pg-graphql/__tests__/pg-graphql-query-builder.spec.ts @@ -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 { diff --git a/server/src/tenant/entity-resolver/pg-graphql/pg-graphql-query-builder.util.ts b/server/src/tenant/entity-resolver/pg-graphql/pg-graphql-query-builder.util.ts index 253a35176..7120d7014 100644 --- a/server/src/tenant/entity-resolver/pg-graphql/pg-graphql-query-builder.util.ts +++ b/server/src/tenant/entity-resolver/pg-graphql/pg-graphql-query-builder.util.ts @@ -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} diff --git a/server/src/tenant/entity-resolver/pg-graphql/pg-graphql-query-runner.util.ts b/server/src/tenant/entity-resolver/pg-graphql/pg-graphql-query-runner.util.ts index 392449401..bd19cb0df 100644 --- a/server/src/tenant/entity-resolver/pg-graphql/pg-graphql-query-runner.util.ts +++ b/server/src/tenant/entity-resolver/pg-graphql/pg-graphql-query-runner.util.ts @@ -58,18 +58,30 @@ export class PGGraphQLQueryRunner { return parseResult(result); } - async findMany(): Promise { - const query = this.queryBuilder.findMany(); + async findMany(args: { + first?: number; + last?: number; + before?: string; + after?: string; + filter?: any; + orderBy?: any; + }): Promise { + 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 { + async findOne(args: { filter?: any }): Promise { + 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 { diff --git a/server/src/tenant/entity-resolver/utils/__tests__/convert-arguments.spec.ts b/server/src/tenant/entity-resolver/utils/__tests__/convert-arguments.spec.ts index ff73354c0..7f1a152cc 100644 --- a/server/src/tenant/entity-resolver/utils/__tests__/convert-arguments.spec.ts +++ b/server/src/tenant/entity-resolver/utils/__tests__/convert-arguments.spec.ts @@ -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); + }); }); diff --git a/server/src/tenant/entity-resolver/utils/__tests__/generate-args-input.spec.ts b/server/src/tenant/entity-resolver/utils/__tests__/generate-args-input.spec.ts new file mode 100644 index 000000000..db702ea66 --- /dev/null +++ b/server/src/tenant/entity-resolver/utils/__tests__/generate-args-input.spec.ts @@ -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"'), + ); + }); +}); diff --git a/server/src/tenant/entity-resolver/utils/__tests__/get-fields-aliases.spec.ts b/server/src/tenant/entity-resolver/utils/__tests__/get-fields-aliases.spec.ts deleted file mode 100644 index d9a2de30a..000000000 --- a/server/src/tenant/entity-resolver/utils/__tests__/get-fields-aliases.spec.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/server/src/tenant/entity-resolver/utils/convert-arguments.util.ts b/server/src/tenant/entity-resolver/utils/convert-arguments.util.ts index f98149631..d90b5b152 100644 --- a/server/src/tenant/entity-resolver/utils/convert-arguments.util.ts +++ b/server/src/tenant/entity-resolver/utils/convert-arguments.util.ts @@ -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); }; diff --git a/server/src/tenant/entity-resolver/utils/generate-args-input.util.ts b/server/src/tenant/entity-resolver/utils/generate-args-input.util.ts new file mode 100644 index 000000000..ceafbf624 --- /dev/null +++ b/server/src/tenant/entity-resolver/utils/generate-args-input.util.ts @@ -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; +}; diff --git a/server/src/tenant/entity-resolver/utils/get-fields-aliases.util.ts b/server/src/tenant/entity-resolver/utils/get-fields-aliases.util.ts deleted file mode 100644 index 668f70bc2..000000000 --- a/server/src/tenant/entity-resolver/utils/get-fields-aliases.util.ts +++ /dev/null @@ -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; -}; diff --git a/server/src/tenant/schema-builder/graphql-types/enum/order-by-direction.type.ts b/server/src/tenant/schema-builder/graphql-types/enum/order-by-direction.type.ts new file mode 100644 index 000000000..b1ff49fcc --- /dev/null +++ b/server/src/tenant/schema-builder/graphql-types/enum/order-by-direction.type.ts @@ -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', + }, + }, +}); diff --git a/server/src/tenant/schema-builder/graphql-types/input/date-time-filter.type.ts b/server/src/tenant/schema-builder/graphql-types/input/date-time-filter.type.ts new file mode 100644 index 000000000..1ffe15306 --- /dev/null +++ b/server/src/tenant/schema-builder/graphql-types/input/date-time-filter.type.ts @@ -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 }, + }, +}); diff --git a/server/src/tenant/schema-builder/graphql-types/input/datetime-filter.type.ts b/server/src/tenant/schema-builder/graphql-types/input/datetime-filter.type.ts deleted file mode 100644 index 2f3595e27..000000000 --- a/server/src/tenant/schema-builder/graphql-types/input/datetime-filter.type.ts +++ /dev/null @@ -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 }, - }, -}); diff --git a/server/src/tenant/schema-builder/graphql-types/scalars/cursor.scalar.ts b/server/src/tenant/schema-builder/graphql-types/scalars/cursor.scalar.ts new file mode 100644 index 000000000..abb4d3855 --- /dev/null +++ b/server/src/tenant/schema-builder/graphql-types/scalars/cursor.scalar.ts @@ -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; + }, +}); diff --git a/server/src/tenant/schema-builder/graphql-types/scalars/datetime.scalar.ts b/server/src/tenant/schema-builder/graphql-types/scalars/date-time.scalar.ts similarity index 92% rename from server/src/tenant/schema-builder/graphql-types/scalars/datetime.scalar.ts rename to server/src/tenant/schema-builder/graphql-types/scalars/date-time.scalar.ts index 05a081f22..c907b4dd4 100644 --- a/server/src/tenant/schema-builder/graphql-types/scalars/datetime.scalar.ts +++ b/server/src/tenant/schema-builder/graphql-types/scalars/date-time.scalar.ts @@ -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); diff --git a/server/src/tenant/schema-builder/graphql-types/scalars/index.ts b/server/src/tenant/schema-builder/graphql-types/scalars/index.ts new file mode 100644 index 000000000..f892e2d2d --- /dev/null +++ b/server/src/tenant/schema-builder/graphql-types/scalars/index.ts @@ -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, +]; diff --git a/server/src/tenant/schema-builder/schema-builder.service.ts b/server/src/tenant/schema-builder/schema-builder.service.ts index dbcf4c317..619af2fb9 100644 --- a/server/src/tenant/schema-builder/schema-builder.service.ts +++ b/server/src/tenant/schema-builder/schema-builder.service.ts @@ -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], }); } } diff --git a/server/src/tenant/schema-builder/utils/__tests__/generate-filter-input-type.spec.ts b/server/src/tenant/schema-builder/utils/__tests__/generate-filter-input-type.spec.ts new file mode 100644 index 000000000..65aa30825 --- /dev/null +++ b/server/src/tenant/schema-builder/utils/__tests__/generate-filter-input-type.spec.ts @@ -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); + } + }); +}); diff --git a/server/src/tenant/schema-builder/utils/__tests__/generate-object-type.spec.ts b/server/src/tenant/schema-builder/utils/__tests__/generate-object-type.spec.ts index 808282267..073532fae 100644 --- a/server/src/tenant/schema-builder/utils/__tests__/generate-object-type.spec.ts +++ b/server/src/tenant/schema-builder/utils/__tests__/generate-object-type.spec.ts @@ -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'); } diff --git a/server/src/tenant/schema-builder/utils/__tests__/generate-order-by-input-type.spec.ts b/server/src/tenant/schema-builder/utils/__tests__/generate-order-by-input-type.spec.ts new file mode 100644 index 000000000..ddce4a73c --- /dev/null +++ b/server/src/tenant/schema-builder/utils/__tests__/generate-order-by-input-type.spec.ts @@ -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); + }); +}); diff --git a/server/src/tenant/schema-builder/utils/generate-filter-input-type.util.ts b/server/src/tenant/schema-builder/utils/generate-filter-input-type.util.ts new file mode 100644 index 000000000..1907a9c3d --- /dev/null +++ b/server/src/tenant/schema-builder/utils/generate-filter-input-type.util.ts @@ -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; +}; diff --git a/server/src/tenant/schema-builder/utils/generate-object-type.util.ts b/server/src/tenant/schema-builder/utils/generate-object-type.util.ts index f9192e9bd..e198e3d0f 100644 --- a/server/src/tenant/schema-builder/utils/generate-object-type.util.ts +++ b/server/src/tenant/schema-builder/utils/generate-object-type.util.ts @@ -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) }, }; /** diff --git a/server/src/tenant/schema-builder/utils/generate-order-by-input-type.util.ts b/server/src/tenant/schema-builder/utils/generate-order-by-input-type.util.ts new file mode 100644 index 000000000..c808dfb1d --- /dev/null +++ b/server/src/tenant/schema-builder/utils/generate-order-by-input-type.util.ts @@ -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, + }); +};