From 830318d18efb759150e0b6fe7512e3c350e46360 Mon Sep 17 00:00:00 2001 From: Dmitriy Lazarev Date: Fri, 23 Feb 2024 11:14:47 +0400 Subject: [PATCH 1/8] Add `entities` GraphQL query Signed-off-by: Dmitriy Lazarev --- .changeset/selfish-kiwis-work.md | 8 + packages/backend/src/plugins/graphql.ts | 1 + .../package.json | 5 +- .../src/catalog/catalog.graphql | 19 +- .../src/catalog/catalog.ts | 36 +- .../src/common/common.graphql | 121 +++++ .../src/common/common.ts | 28 ++ .../src/common/index.ts | 1 + .../src/entitiesLoadFn.ts | 2 +- .../src/generateInputTypes.test.ts | 213 +++++++++ .../src/generateInputTypes.ts | 442 ++++++++++++++++++ .../src/index.ts | 1 + .../src/relation/relation.graphql | 6 +- .../src/relation/relation.ts | 14 +- .../src/relationDirectiveMapper.test.ts | 27 +- .../src/resolvers.ts | 393 ++++++++++++++++ plugins/graphql-backend-node/package.json | 2 +- plugins/graphql-backend/package.json | 2 +- yarn.lock | 8 +- 19 files changed, 1252 insertions(+), 77 deletions(-) create mode 100644 .changeset/selfish-kiwis-work.md create mode 100644 plugins/graphql-backend-module-catalog/src/common/common.graphql create mode 100644 plugins/graphql-backend-module-catalog/src/common/common.ts create mode 100644 plugins/graphql-backend-module-catalog/src/common/index.ts create mode 100644 plugins/graphql-backend-module-catalog/src/generateInputTypes.test.ts create mode 100644 plugins/graphql-backend-module-catalog/src/generateInputTypes.ts create mode 100644 plugins/graphql-backend-module-catalog/src/resolvers.ts diff --git a/.changeset/selfish-kiwis-work.md b/.changeset/selfish-kiwis-work.md new file mode 100644 index 0000000000..c75af3a617 --- /dev/null +++ b/.changeset/selfish-kiwis-work.md @@ -0,0 +1,8 @@ +--- +'@frontside/backstage-plugin-graphql-backend-module-catalog': minor +'@frontside/backstage-plugin-graphql-backend-node': patch +'@frontside/backstage-plugin-graphql-backend': patch +'backend': patch +--- + +Add GraphQL `entities` query with generated filter input types diff --git a/packages/backend/src/plugins/graphql.ts b/packages/backend/src/plugins/graphql.ts index 4d333db13f..c60373ce3a 100644 --- a/packages/backend/src/plugins/graphql.ts +++ b/packages/backend/src/plugins/graphql.ts @@ -11,5 +11,6 @@ export default async function createPlugin( logger: env.logger, modules: [myModule, Catalog()], loaders: { ...createCatalogLoader(env.catalog) }, + context: (ctx) => ({ ...ctx, catalog: env.catalog }), }); } diff --git a/plugins/graphql-backend-module-catalog/package.json b/plugins/graphql-backend-module-catalog/package.json index d6272b8f59..4f79adfb14 100644 --- a/plugins/graphql-backend-module-catalog/package.json +++ b/plugins/graphql-backend-module-catalog/package.json @@ -47,14 +47,15 @@ "@backstage/plugin-auth-node": "^0.4.1", "@backstage/plugin-catalog-node": "^1.5.0", "@frontside/backstage-plugin-graphql-backend": "^0.1.6", - "@frontside/hydraphql": "^0.1.2", + "@frontside/hydraphql": "^0.1.3", "@graphql-tools/load-files": "^7.0.0", "@graphql-tools/utils": "^10.0.0", "dataloader": "^2.1.0", "graphql": "^16.6.0", "graphql-modules": "^2.3.0", "graphql-relay": "^0.10.0", - "graphql-type-json": "^0.3.2" + "graphql-type-json": "^0.3.2", + "lodash": "^4.17.21" }, "devDependencies": { "@backstage/cli": "^0.24.0", diff --git a/plugins/graphql-backend-module-catalog/src/catalog/catalog.graphql b/plugins/graphql-backend-module-catalog/src/catalog/catalog.graphql index 772ced7e79..317588f696 100644 --- a/plugins/graphql-backend-module-catalog/src/catalog/catalog.graphql +++ b/plugins/graphql-backend-module-catalog/src/catalog/catalog.graphql @@ -1,12 +1,3 @@ -directive @relation( - name: String - nodeType: String - kind: String -) on FIELD_DEFINITION - -scalar JSON -scalar JSONObject - type KeyValuePair { key: String! value: String! @@ -16,8 +7,7 @@ union Ownable = API | Component | Domain | Resource | System | Template union Dependency = Component | Resource union Owner = User | Group -interface Entity - @implements(interface: "Node") +extend interface Entity @discriminates(with: "kind", opaqueType: "OpaqueEntity") { name: String! @field(at: "metadata.name") kind: String! @field(at: "kind") @@ -123,7 +113,8 @@ type Step { interface Template @implements(interface: "Entity") - @discriminates(with: "spec.type", opaqueType: "OpaqueTemplate") { + @discriminates(with: "spec.type", opaqueType: "OpaqueTemplate") + @discriminationAlias(value: "service", type: "ServiceTemplate") { type: String! @field(at: "spec.type") parameters: JSONObject @field(at: "spec.parameters") steps: [Step!]! @field(at: "spec.steps") @@ -162,7 +153,3 @@ type User @implements(interface: "Entity") { memberOf: Connection @relation(nodeType: "Group") ownerOf: Connection @relation(nodeType: "Ownable") } - -extend type Query { - entity(kind: String!, name: String!, namespace: String): Entity -} diff --git a/plugins/graphql-backend-module-catalog/src/catalog/catalog.ts b/plugins/graphql-backend-module-catalog/src/catalog/catalog.ts index b96dc854bf..41648fee76 100644 --- a/plugins/graphql-backend-module-catalog/src/catalog/catalog.ts +++ b/plugins/graphql-backend-module-catalog/src/catalog/catalog.ts @@ -1,14 +1,8 @@ import { createModule } from 'graphql-modules'; -import GraphQLJSON, { GraphQLJSONObject } from 'graphql-type-json'; -import { relationDirectiveMapper } from '../relationDirectiveMapper'; -import { - GraphQLModule, - encodeId, -} from '@frontside/hydraphql'; -import { stringifyEntityRef } from '@backstage/catalog-model'; import { loadFilesSync } from '@graphql-tools/load-files'; import { resolvePackagePath } from '@backstage/backend-common'; -import { CATALOG_SOURCE } from '../constants'; +import { Relation } from '../relation'; +import { GraphQLModule } from '@frontside/hydraphql'; const catalogSchemaPath = resolvePackagePath( '@frontside/backstage-plugin-graphql-backend-module-catalog', @@ -17,13 +11,13 @@ const catalogSchemaPath = resolvePackagePath( /** @public */ export const Catalog = (): GraphQLModule => ({ - mappers: { relation: relationDirectiveMapper }, + mappers: { ...Relation().mappers }, + postTransform: Relation().postTransform, module: createModule({ - id: 'catalog', - typeDefs: loadFilesSync(catalogSchemaPath), + id: 'catalog-entities', + typeDefs: [...Relation().module.typeDefs, ...loadFilesSync(catalogSchemaPath)], resolvers: { - JSON: GraphQLJSON, - JSONObject: GraphQLJSONObject, + ...Relation().module.config.resolvers, Entity: { labels: (labels: Record) => labels @@ -37,22 +31,6 @@ export const Catalog = (): GraphQLModule => ({ })) : null, }, - Query: { - entity: ( - _: any, - { - name, - kind, - namespace = 'default', - }: { name: string; kind: string; namespace: string }, - ): { id: string } => ({ - id: encodeId({ - source: CATALOG_SOURCE, - typename: 'Entity', - query: { ref: stringifyEntityRef({ name, kind, namespace }) }, - }), - }), - }, }, }) }); diff --git a/plugins/graphql-backend-module-catalog/src/common/common.graphql b/plugins/graphql-backend-module-catalog/src/common/common.graphql new file mode 100644 index 0000000000..be20b2894c --- /dev/null +++ b/plugins/graphql-backend-module-catalog/src/common/common.graphql @@ -0,0 +1,121 @@ +directive @relation( + name: String + nodeType: String + kind: String +) on FIELD_DEFINITION + +scalar JSON +scalar JSONObject + +enum OrderDirection { + ASC + DESC +} + +type EntityConnection implements Connection { + pageInfo: PageInfo! + edges: [EntityEdge!]! + count: Int +} + +type EntityEdge implements Edge { + cursor: String! + node: Entity! +} + +extend type Query { + entity(kind: String!, name: String!, namespace: String): Entity + entities( + first: Int, + after: String, + last: Int, + before: String, + filter: EntityFilter, + rawFilter: EntityRawFilter, + ): EntityConnection +} + +input EntityOrderField { + _dummy: OrderDirection +} + +input EntityTextFilterFields { + _dummy: Boolean +} + +input EntityTextFilter { + term: String! + fields: EntityTextFilterFields +} + +input EntityFilterExpression { + _dummy: [JSON!] +} + +""" +{ + order: [ + { fieldA: ASC } + { fieldB: DESC } + { fieldC: [{ fieldD: ASC }] } + { fieldE: { order: ASC } } + { + fieldE: { + fields: [{ fieldF: DESC }, { fieldG: ASC }] + } + } + ] + search: { + term: "substring" + fields: { + fieldA: true + fieldB: true + fieldC: { fieldD: true } + fieldE: { + include: true + fields: { fieldF: true, fieldG: true } + } + } + } + match: [ + { fieldA: ["value1", "value2"], fieldB: ["value3"] } + { fieldC: { fieldD: ["value4"] } } + { + fieldE: { + values: ["value5", "value6"], + fields: { fieldF: ["value7"], fieldG: ["value8"] } + } + } + ] +} +""" +input EntityFilter { + order: [EntityOrderField!] + search: EntityTextFilter + match: [EntityFilterExpression!] +} + +input EntityRawFilterField { + key: String! + values: [JSON!]! +} + +input EntityRawFilterExpression { + fields: [EntityRawFilterField!]! +} + +input EntityRawOrderField { + field: String! + order: OrderDirection! +} + +input EntityRawTextFilter { + term: String! + fields: [String!] +} + +input EntityRawFilter { + filter: [EntityRawFilterExpression!] + orderFields: [EntityRawOrderField!] + fullTextFilter: EntityRawTextFilter +} diff --git a/plugins/graphql-backend-module-catalog/src/common/common.ts b/plugins/graphql-backend-module-catalog/src/common/common.ts new file mode 100644 index 0000000000..1e388d6c32 --- /dev/null +++ b/plugins/graphql-backend-module-catalog/src/common/common.ts @@ -0,0 +1,28 @@ +import { createModule } from 'graphql-modules'; +import { relationDirectiveMapper } from '../relationDirectiveMapper'; +import { GraphQLModule } from '@frontside/hydraphql'; +import { loadFilesSync } from '@graphql-tools/load-files'; +import { resolvePackagePath } from '@backstage/backend-common'; +import { queryResolvers } from '../resolvers'; +import GraphQLJSON, { GraphQLJSONObject } from 'graphql-type-json'; +import { generateEntitiesQueryInputTypes } from '../generateInputTypes'; + +const commonSchemaPath = resolvePackagePath( + '@frontside/backstage-plugin-graphql-backend-module-catalog', + 'src/common/common.graphql', +); + +/** @public */ +export const Common = (): GraphQLModule => ({ + mappers: { relation: relationDirectiveMapper }, + postTransform: generateEntitiesQueryInputTypes, + module: createModule({ + id: 'catalog-common', + typeDefs: loadFilesSync(commonSchemaPath), + resolvers: { + JSON: GraphQLJSON, + JSONObject: GraphQLJSONObject, + Query: queryResolvers() + } + }) +}); diff --git a/plugins/graphql-backend-module-catalog/src/common/index.ts b/plugins/graphql-backend-module-catalog/src/common/index.ts new file mode 100644 index 0000000000..d0b9323665 --- /dev/null +++ b/plugins/graphql-backend-module-catalog/src/common/index.ts @@ -0,0 +1 @@ +export * from './common'; diff --git a/plugins/graphql-backend-module-catalog/src/entitiesLoadFn.ts b/plugins/graphql-backend-module-catalog/src/entitiesLoadFn.ts index 7f0a24d3c9..d4a07b8254 100644 --- a/plugins/graphql-backend-module-catalog/src/entitiesLoadFn.ts +++ b/plugins/graphql-backend-module-catalog/src/entitiesLoadFn.ts @@ -11,7 +11,7 @@ export const createCatalogLoader = (catalog: CatalogApi) => ({ queries: readonly (NodeQuery | undefined)[], context: GraphQLContext & { request?: Request } ): Promise> => { - // TODO: Support fields + // TODO: Support fields, to allow use loader in `@resolve` directive const request = context.request; const token = getBearerTokenFromAuthorizationHeader(request?.headers.get('authorization')); const entityRefs = queries.reduce( diff --git a/plugins/graphql-backend-module-catalog/src/generateInputTypes.test.ts b/plugins/graphql-backend-module-catalog/src/generateInputTypes.test.ts new file mode 100644 index 0000000000..a3e58b918e --- /dev/null +++ b/plugins/graphql-backend-module-catalog/src/generateInputTypes.test.ts @@ -0,0 +1,213 @@ +import { + transformSchema, +} from '@frontside/hydraphql'; +import { DocumentNode, GraphQLNamedType, printType } from 'graphql'; +import { Module, createModule, gql } from 'graphql-modules'; +import { Relation } from './relation/relation'; + +describe('generateEntitiesQueryInputTypes', () => { + const transform = (source: DocumentNode, anotherModule?: Module) => + transformSchema([ + Relation(), + createModule({ + id: 'generateEntitiesQueryInputTypes', + typeDefs: source, + }), + ...(anotherModule ? [anotherModule] : []), + ]); + + it('should generate dummy input types', () => { + const schema = transform(gql` + type Foo { + id: ID! + } + `); + + expect(printType(schema.getType('EntityOrderField') as GraphQLNamedType).split('\n')).toEqual([ + 'input EntityOrderField {', + ' _dummy: OrderDirection', + '}', + ]); + expect(printType(schema.getType('EntityTextFilterFields') as GraphQLNamedType).split('\n')).toEqual([ + 'input EntityTextFilterFields {', + ' _dummy: Boolean', + '}', + ]); + expect(printType(schema.getType('EntityFilterExpression') as GraphQLNamedType).split('\n')).toEqual([ + 'input EntityFilterExpression {', + ' _dummy: [JSON!]', + '}', + ]); + }) + + it('should generate plain input types for primitive fields', () => { + const schema = transform(gql` + extend interface Entity { + name: String! @field(at: "metadata.name") + kind: String! @field(at: "kind") + namespace: String! @field(at: "metadata.namespace", default: "default") + apiVersion: String! @field(at: "apiVersion") + title: String @field(at: "metadata.title") + description: String @field(at: "metadata.description") + } + `); + + expect(printType(schema.getType('EntityOrderField') as GraphQLNamedType).split('\n')).toEqual([ + 'input EntityOrderField {', + ' name: OrderDirection', + ' kind: OrderDirection', + ' namespace: OrderDirection', + ' apiVersion: OrderDirection', + ' title: OrderDirection', + ' description: OrderDirection', + '}', + ]); + expect(printType(schema.getType('EntityTextFilterFields') as GraphQLNamedType).split('\n')).toEqual([ + 'input EntityTextFilterFields {', + ' name: Boolean', + ' kind: Boolean', + ' namespace: Boolean', + ' apiVersion: Boolean', + ' title: Boolean', + ' description: Boolean', + '}', + ]); + expect(printType(schema.getType('EntityFilterExpression') as GraphQLNamedType).split('\n')).toEqual([ + 'input EntityFilterExpression {', + ' name: [JSON!]', + ' kind: [JSON!]', + ' namespace: [JSON!]', + ' apiVersion: [JSON!]', + ' title: [JSON!]', + ' description: [JSON!]', + '}', + ]); + }) + + it('should generate input types for composite fields', () => { + const schema = transform(gql` + extend interface Entity { + metadata: Metadata! @field(at: "metadata") + } + + type Metadata { + name: String! @field + namespace: String! @field + } + `); + + expect(printType(schema.getType('EntityOrderField') as GraphQLNamedType).split('\n')).toEqual([ + 'input EntityOrderField {', + ' metadata: [EntityOrderField_Metadata!]', + '}', + ]); + expect(printType(schema.getType('EntityOrderField_Metadata') as GraphQLNamedType).split('\n')).toEqual([ + 'input EntityOrderField_Metadata {', + ' name: OrderDirection', + ' namespace: OrderDirection', + '}', + ]); + + expect(printType(schema.getType('EntityTextFilterFields') as GraphQLNamedType).split('\n')).toEqual([ + 'input EntityTextFilterFields {', + ' metadata: EntityTextFilterFields_Metadata', + '}', + ]); + expect(printType(schema.getType('EntityTextFilterFields_Metadata') as GraphQLNamedType).split('\n')).toEqual([ + 'input EntityTextFilterFields_Metadata {', + ' name: Boolean', + ' namespace: Boolean', + '}', + ]); + + expect(printType(schema.getType('EntityFilterExpression') as GraphQLNamedType).split('\n')).toEqual([ + 'input EntityFilterExpression {', + ' metadata: EntityFilterExpression_Metadata', + '}', + ]); + expect(printType(schema.getType('EntityFilterExpression_Metadata') as GraphQLNamedType).split('\n')).toEqual([ + 'input EntityFilterExpression_Metadata {', + ' name: [JSON!]', + ' namespace: [JSON!]', + '}', + ]); + }) + + it('should generate input types for mixed fields (plain and composite)', () => { + const schema = transform(gql` + extend interface Entity @discriminates(with: "kind") { + kind: String! @field(at: "kind") + } + + type Component @implements(interface: "Entity") { + target: String! @field(at: "spec.target") + } + + type Location @implements(interface: "Entity") { + target: Target! @field(at: "spec.target") + } + + type Target { + host: String! @field + port: Int! @field + } + `); + + expect(printType(schema.getType('EntityOrderField') as GraphQLNamedType).split('\n')).toEqual([ + 'input EntityOrderField {', + ' kind: OrderDirection', + ' target: EntityOrderField_Target', + '}', + ]); + expect(printType(schema.getType('EntityOrderField_Target') as GraphQLNamedType).split('\n')).toEqual([ + 'input EntityOrderField_Target {', + ' order: OrderDirection', + ' fields: [EntityOrderField__Target!]', + '}', + ]); + expect(printType(schema.getType('EntityOrderField__Target') as GraphQLNamedType).split('\n')).toEqual([ + 'input EntityOrderField__Target {', + ' host: OrderDirection', + ' port: OrderDirection', + '}', + ]); + + expect(printType(schema.getType('EntityTextFilterFields') as GraphQLNamedType).split('\n')).toEqual([ + 'input EntityTextFilterFields {', + ' kind: Boolean', + ' target: EntityTextFilterFields_Target', + '}', + ]); + expect(printType(schema.getType('EntityTextFilterFields_Target') as GraphQLNamedType).split('\n')).toEqual([ + 'input EntityTextFilterFields_Target {', + ' include: Boolean', + ' fields: EntityTextFilterFields__Target', + '}', + ]); + expect(printType(schema.getType('EntityTextFilterFields__Target') as GraphQLNamedType).split('\n')).toEqual([ + 'input EntityTextFilterFields__Target {', + ' host: Boolean', + ' port: Boolean', + '}', + ]); + + expect(printType(schema.getType('EntityFilterExpression') as GraphQLNamedType).split('\n')).toEqual([ + 'input EntityFilterExpression {', + ' kind: [JSON!]', + ' target: EntityFilterExpression_Target', + '}', + ]); + expect(printType(schema.getType('EntityFilterExpression_Target') as GraphQLNamedType).split('\n')).toEqual([ + 'input EntityFilterExpression_Target {', + ' values: [JSON!]', + ' fields: EntityFilterExpression__Target', + '}', + ]); + expect(printType(schema.getType('EntityFilterExpression__Target') as GraphQLNamedType).split('\n')).toEqual([ + 'input EntityFilterExpression__Target {', + ' host: [JSON!]', + ' port: [JSON!]', + '}', + ]); + }) +}) diff --git a/plugins/graphql-backend-module-catalog/src/generateInputTypes.ts b/plugins/graphql-backend-module-catalog/src/generateInputTypes.ts new file mode 100644 index 0000000000..1994071724 --- /dev/null +++ b/plugins/graphql-backend-module-catalog/src/generateInputTypes.ts @@ -0,0 +1,442 @@ +import { + GraphQLSchema, + isInterfaceType, + isInputObjectType, + GraphQLInputObjectType, + isEnumType, + GraphQLBoolean, + GraphQLEnumType, + isUnionType, + GraphQLCompositeType, + getNamedType, + GraphQLList, + GraphQLNonNull, + GraphQLInputType, + GraphQLType, + isLeafType, + isWrappingType, + isCompositeType, +} from 'graphql'; +import { addTypes, getDirective } from '@graphql-tools/utils'; +import GraphQLJSON from 'graphql-type-json'; + +export function isWrappingLeafType(type: GraphQLType): boolean { + if (isLeafType(type)) return true + if (isWrappingType(type)) return isWrappingLeafType(type.ofType) + return false +} + +export function isWrappingCompositeType(type: GraphQLType): boolean { + if (isCompositeType(type)) return true + if (isWrappingType(type)) return isWrappingCompositeType(type.ofType) + return false +} + +function mergeLeafAndCompositeOrderFieldTypes( + fieldName: string, + orderFieldTypeName: string, + compositeOrderFieldType: GraphQLInputType, + orderType: GraphQLEnumType, +) { + // NOTE: Should we check if the type is already in the schema? + return { + type: new GraphQLInputObjectType({ + name: `${orderFieldTypeName}_${fieldName[0].toUpperCase()}${fieldName.slice( + 1, + )}`, + fields: { + order: { type: orderType }, + fields: { + type: new GraphQLList( + new GraphQLNonNull( + new GraphQLInputObjectType({ + ...( + getNamedType( + compositeOrderFieldType, + ) as GraphQLInputObjectType + ).toConfig(), + name: `${orderFieldTypeName}__${fieldName[0].toUpperCase()}${fieldName.slice( + 1, + )}`, + }), + ), + ), + }, + }, + }), + }; +} + +function mergeLeafAndCompositeTextFilterFieldsTypes( + fieldName: string, + textFilterFieldsTypeName: string, + compositeTextFilterFieldsType: GraphQLInputType, +) { + return { + type: new GraphQLInputObjectType({ + name: `${textFilterFieldsTypeName}_${fieldName[0].toUpperCase()}${fieldName.slice( + 1, + )}`, + fields: { + include: { type: GraphQLBoolean }, + fields: { + type: new GraphQLInputObjectType({ + ...( + getNamedType( + compositeTextFilterFieldsType, + ) as GraphQLInputObjectType + ).toConfig(), + name: `${textFilterFieldsTypeName}__${fieldName[0].toUpperCase()}${fieldName.slice( + 1, + )}`, + }), + }, + }, + }), + }; +} + +function mergeLeafAndCompositeFilterExpressionTypes( + fieldName: string, + filterExpressionTypeName: string, + compositeFilterExpressionType: GraphQLInputType, +) { + return { + type: new GraphQLInputObjectType({ + name: `${filterExpressionTypeName}_${fieldName[0].toUpperCase()}${fieldName.slice( + 1, + )}`, + fields: { + values: { type: new GraphQLList(new GraphQLNonNull(GraphQLJSON)) }, + fields: { + type: new GraphQLInputObjectType({ + ...( + getNamedType( + compositeFilterExpressionType, + ) as GraphQLInputObjectType + ).toConfig(), + name: `${filterExpressionTypeName}__${fieldName[0].toUpperCase()}${fieldName.slice( + 1, + )}`, + }), + }, + }, + }), + }; +} + +function getTypeConfigs( + fieldName: string, + fieldTypes: Map, + orderFieldTypeConfig: ReturnType, + textFilterFieldsTypeConfig: ReturnType, + filterExpressionTypeConfig: ReturnType, +) { + if (fieldTypes.get(fieldName)?.isComposite) { + if (fieldTypes.get(fieldName)?.isLeaf) { + const mixedOrderFieldType = orderFieldTypeConfig.fields[fieldName] + .type as GraphQLInputObjectType; + const mixedTextFilterFieldsType = textFilterFieldsTypeConfig.fields[ + fieldName + ].type as GraphQLInputObjectType; + const mixedFilterExpressionType = filterExpressionTypeConfig.fields[ + fieldName + ].type as GraphQLInputObjectType; + + return { + // { + // order: OrderDirection + // fields: [EntityOrderField${FieldName}!] + // } + compositeOrderFieldTypeConfig: ( + getNamedType( + mixedOrderFieldType.toConfig().fields.fields.type, + ) as GraphQLInputObjectType + ).toConfig(), + // { + // include: Boolean + // fields: EntityTextFilterFields${FieldName} + // } + compositeTextFilterFieldsTypeConfig: ( + getNamedType( + mixedTextFilterFieldsType.toConfig().fields.fields.type, + ) as GraphQLInputObjectType + ).toConfig(), + // { + // values: JSON + // fields: [EntityFilterExpression${FieldName}!] + // } + compositeFilterExpressionTypeConfig: ( + getNamedType( + mixedFilterExpressionType.toConfig().fields.fields.type, + ) as GraphQLInputObjectType + ).toConfig(), + }; + } + return { + compositeOrderFieldTypeConfig: ( + getNamedType( + orderFieldTypeConfig.fields[fieldName].type, + ) as GraphQLInputObjectType + ).toConfig(), + compositeTextFilterFieldsTypeConfig: ( + getNamedType( + textFilterFieldsTypeConfig.fields[fieldName].type, + ) as GraphQLInputObjectType + ).toConfig(), + compositeFilterExpressionTypeConfig: ( + getNamedType( + filterExpressionTypeConfig.fields[fieldName].type, + ) as GraphQLInputObjectType + ).toConfig(), + }; + } + return { + compositeOrderFieldTypeConfig: { + name: `${ + orderFieldTypeConfig.name + }_${fieldName[0].toUpperCase()}${fieldName.slice(1)}`, + fields: {}, + } as ReturnType, + compositeTextFilterFieldsTypeConfig: { + name: `${ + textFilterFieldsTypeConfig.name + }_${fieldName[0].toUpperCase()}${fieldName.slice(1)}`, + fields: {}, + } as ReturnType, + compositeFilterExpressionTypeConfig: { + name: `${ + filterExpressionTypeConfig.name + }_${fieldName[0].toUpperCase()}${fieldName.slice(1)}`, + fields: {}, + } as ReturnType, + }; +} + +function processTypes( + schema: GraphQLSchema, + types: readonly GraphQLCompositeType[], + { + isNested = false, + orderDirectionType, + orderFieldTypeConfig, + textFilterFieldsTypeConfig, + filterExpressionTypeConfig, + }: { + isNested?: boolean; + orderDirectionType: GraphQLEnumType; + orderFieldTypeConfig: ReturnType; + textFilterFieldsTypeConfig: ReturnType; + filterExpressionTypeConfig: ReturnType; + }, +) { + const fieldTypes = new Map< + string, + { isLeaf?: boolean; isComposite?: boolean } + >(); + + types.forEach(type => { + if (isUnionType(type)) { + processTypes(schema, type.getTypes(), { + isNested, + orderDirectionType, + orderFieldTypeConfig, + textFilterFieldsTypeConfig, + filterExpressionTypeConfig, + }); + return; + } + if (isInterfaceType(type)) { + processTypes(schema, [...schema.getImplementations(type).interfaces, ...schema.getImplementations(type).objects], { + isNested, + orderDirectionType, + orderFieldTypeConfig, + textFilterFieldsTypeConfig, + filterExpressionTypeConfig, + }); + } + Object.entries(type.getFields()).forEach(([fieldName, field]) => { + const [fieldDirective] = getDirective(schema, field, 'field') ?? []; + if (!fieldDirective && !isNested) return; + + // const sourceFieldName = fieldDirective.at ?? fieldName + + if ( + isWrappingLeafType(field.type) && + !fieldTypes.get(fieldName)?.isLeaf + ) { + if (fieldTypes.get(fieldName)?.isComposite) { + // NOTE: Should we check if the type is already in the schema? + orderFieldTypeConfig.fields[fieldName] = + mergeLeafAndCompositeOrderFieldTypes( + fieldName, + orderFieldTypeConfig.name, + orderFieldTypeConfig.fields[fieldName].type, + orderDirectionType, + ); + + textFilterFieldsTypeConfig.fields[fieldName] = + mergeLeafAndCompositeTextFilterFieldsTypes( + fieldName, + textFilterFieldsTypeConfig.name, + textFilterFieldsTypeConfig.fields[fieldName].type, + ); + + filterExpressionTypeConfig.fields[fieldName] = + mergeLeafAndCompositeFilterExpressionTypes( + fieldName, + filterExpressionTypeConfig.name, + filterExpressionTypeConfig.fields[fieldName].type, + ); + } else { + orderFieldTypeConfig.fields[fieldName] = { type: orderDirectionType }; + textFilterFieldsTypeConfig.fields[fieldName] = { + type: GraphQLBoolean, + }; + // NOTE: Should we handle enums differently? + filterExpressionTypeConfig.fields[fieldName] = { + type: new GraphQLList(new GraphQLNonNull(GraphQLJSON)), + }; + } + + fieldTypes.set(fieldName, { isLeaf: true }); + } + + if (isWrappingCompositeType(field.type)) { + const fieldType = getNamedType(field.type) as GraphQLCompositeType; + + const { + compositeOrderFieldTypeConfig, + compositeTextFilterFieldsTypeConfig, + compositeFilterExpressionTypeConfig, + } = getTypeConfigs( + fieldName, + fieldTypes, + orderFieldTypeConfig, + textFilterFieldsTypeConfig, + filterExpressionTypeConfig, + ); + + processTypes(schema, [fieldType], { + isNested: true, + orderDirectionType, + orderFieldTypeConfig: compositeOrderFieldTypeConfig, + textFilterFieldsTypeConfig: compositeTextFilterFieldsTypeConfig, + filterExpressionTypeConfig: compositeFilterExpressionTypeConfig, + }); + + if (fieldTypes.get(fieldName)?.isLeaf) { + orderFieldTypeConfig.fields[fieldName] = + mergeLeafAndCompositeOrderFieldTypes( + fieldName, + orderFieldTypeConfig.name, + new GraphQLInputObjectType(compositeOrderFieldTypeConfig), + orderDirectionType, + ); + + textFilterFieldsTypeConfig.fields[fieldName] = + mergeLeafAndCompositeTextFilterFieldsTypes( + fieldName, + textFilterFieldsTypeConfig.name, + new GraphQLInputObjectType(compositeTextFilterFieldsTypeConfig), + ); + + filterExpressionTypeConfig.fields[fieldName] = + mergeLeafAndCompositeFilterExpressionTypes( + fieldName, + filterExpressionTypeConfig.name, + new GraphQLInputObjectType(compositeFilterExpressionTypeConfig), + ); + } else { + orderFieldTypeConfig.fields[fieldName] = { + type: new GraphQLList( + new GraphQLNonNull( + new GraphQLInputObjectType(compositeOrderFieldTypeConfig), + ), + ), + }; + textFilterFieldsTypeConfig.fields[fieldName] = { + type: new GraphQLInputObjectType( + compositeTextFilterFieldsTypeConfig, + ), + }; + filterExpressionTypeConfig.fields[fieldName] = { + type: new GraphQLInputObjectType(compositeFilterExpressionTypeConfig), + }; + } + + fieldTypes.set(fieldName, { isComposite: true }); + } + }); + }); +} + +// TODO Handle `JSONObject` type +export function generateEntitiesQueryInputTypes( + schema: GraphQLSchema, +): GraphQLSchema { + const entityType = schema.getType('Entity'); + if (!entityType || !isInterfaceType(entityType)) return schema; + + const orderFieldType = schema.getType('EntityOrderField'); + + if (!orderFieldType || !isInputObjectType(orderFieldType)) { + throw new Error('"EntityOrderField" type not found or isn\'t input type'); + } + const orderFieldTypeConfig = orderFieldType.toConfig(); + orderFieldTypeConfig.fields = {}; + + const orderDirectionType = schema.getType('OrderDirection'); + if (!orderDirectionType || !isEnumType(orderDirectionType)) { + throw new Error('"OrderDirection" type not found or isn\'t enum type'); + } + + const textFilterFieldsType = schema.getType('EntityTextFilterFields'); + if (!textFilterFieldsType || !isInputObjectType(textFilterFieldsType)) { + throw new Error( + '"EntityTextFilterFields" type not found or isn\'t input type', + ); + } + const textFilterFieldsTypeConfig = textFilterFieldsType.toConfig(); + textFilterFieldsTypeConfig.fields = {}; + + const filterExpressionType = schema.getType('EntityFilterExpression'); + if (!filterExpressionType || !isInputObjectType(filterExpressionType)) { + throw new Error( + '"EntityFilterExpression" type not found or isn\'t input type', + ); + } + const filterExpressionTypeConfig = filterExpressionType.toConfig(); + filterExpressionTypeConfig.fields = {}; + + processTypes(schema, [entityType], { + orderDirectionType, + orderFieldTypeConfig, + textFilterFieldsTypeConfig, + filterExpressionTypeConfig, + }); + + if (!Object.keys(orderFieldTypeConfig.fields).length) { + orderFieldTypeConfig.fields = { + _dummy: { type: orderDirectionType }, + } + } + + if (!Object.keys(textFilterFieldsTypeConfig.fields).length) { + textFilterFieldsTypeConfig.fields = { + _dummy: { type: GraphQLBoolean }, + } + } + + if (!Object.keys(filterExpressionTypeConfig.fields).length) { + filterExpressionTypeConfig.fields = { + _dummy: { type: new GraphQLList(new GraphQLNonNull(GraphQLJSON)) }, + } + } + + return addTypes(schema, [ + new GraphQLInputObjectType(orderFieldTypeConfig), + new GraphQLInputObjectType(textFilterFieldsTypeConfig), + new GraphQLInputObjectType(filterExpressionTypeConfig), + ]); +} diff --git a/plugins/graphql-backend-module-catalog/src/index.ts b/plugins/graphql-backend-module-catalog/src/index.ts index 8b8b7477ce..1fe8cd2939 100644 --- a/plugins/graphql-backend-module-catalog/src/index.ts +++ b/plugins/graphql-backend-module-catalog/src/index.ts @@ -1,4 +1,5 @@ export * from './helpers'; +export * from './common'; export * from './catalog'; export * from './relation'; export * from './catalogModule'; diff --git a/plugins/graphql-backend-module-catalog/src/relation/relation.graphql b/plugins/graphql-backend-module-catalog/src/relation/relation.graphql index fc7b65b65e..51ed871b51 100644 --- a/plugins/graphql-backend-module-catalog/src/relation/relation.graphql +++ b/plugins/graphql-backend-module-catalog/src/relation/relation.graphql @@ -1,5 +1 @@ -directive @relation( - name: String - nodeType: String - kind: String -) on FIELD_DEFINITION +interface Entity @implements(interface: "Node") diff --git a/plugins/graphql-backend-module-catalog/src/relation/relation.ts b/plugins/graphql-backend-module-catalog/src/relation/relation.ts index 98a9378ae9..5ad140df5d 100644 --- a/plugins/graphql-backend-module-catalog/src/relation/relation.ts +++ b/plugins/graphql-backend-module-catalog/src/relation/relation.ts @@ -1,8 +1,8 @@ import { createModule } from 'graphql-modules'; -import { relationDirectiveMapper } from '../relationDirectiveMapper'; -import { GraphQLModule } from '@frontside/hydraphql'; import { loadFilesSync } from '@graphql-tools/load-files'; import { resolvePackagePath } from '@backstage/backend-common'; +import { GraphQLModule } from '@frontside/hydraphql'; +import { Common } from '../common'; const relationSchemaPath = resolvePackagePath( '@frontside/backstage-plugin-graphql-backend-module-catalog', @@ -11,9 +11,13 @@ const relationSchemaPath = resolvePackagePath( /** @public */ export const Relation = (): GraphQLModule => ({ - mappers: { relation: relationDirectiveMapper }, + mappers: { ...Common().mappers }, + postTransform: Common().postTransform, module: createModule({ - id: 'relation', - typeDefs: loadFilesSync(relationSchemaPath), + id: 'catalog-relation', + typeDefs: [...Common().module.typeDefs, ...loadFilesSync(relationSchemaPath)], + resolvers: { + ...Common().module.config.resolvers, + } }) }); diff --git a/plugins/graphql-backend-module-catalog/src/relationDirectiveMapper.test.ts b/plugins/graphql-backend-module-catalog/src/relationDirectiveMapper.test.ts index 8c38bcfc38..b10f430803 100644 --- a/plugins/graphql-backend-module-catalog/src/relationDirectiveMapper.test.ts +++ b/plugins/graphql-backend-module-catalog/src/relationDirectiveMapper.test.ts @@ -5,27 +5,29 @@ import { } from '@frontside/hydraphql'; import DataLoader from 'dataloader'; import { DocumentNode, GraphQLNamedType, printType } from 'graphql'; -import { createModule, gql } from 'graphql-modules'; +import { Module, createModule, gql } from 'graphql-modules'; import { createGraphQLAPI } from './__testUtils__'; import { Relation } from './relation/relation'; +import { Common } from './common'; describe('mapRelationDirective', () => { - const transform = (source: DocumentNode) => + const transform = (source: DocumentNode, anotherModule?: Module) => transformSchema([ + Common(), Relation(), createModule({ id: 'mapRelationDirective', typeDefs: source, }), + ...(anotherModule ? [anotherModule] : []), ]); it('should add subtypes to a union type', () => { const schema = transform(gql` union Ownable = Entity - interface Entity - @discriminates(with: "kind") - @implements(interface: "Node") { + extend interface Entity + @discriminates(with: "kind") { name: String! } interface Resource @@ -74,9 +76,8 @@ describe('mapRelationDirective', () => { const schema = transform(gql` union Ownable = Entity - interface Entity - @discriminates(with: "kind") - @implements(interface: "Node") { + extend interface Entity + @discriminates(with: "kind") { name: String! } type Resource @implements(interface: "Entity") { @@ -131,7 +132,7 @@ describe('mapRelationDirective', () => { it("should fail if @relation interface doesn't exist", () => { expect(() => transform(gql` - interface Entity { + extend interface Entity { owners: Connection @relation(name: "ownedBy", nodeType: "Owner") } `), @@ -143,7 +144,7 @@ describe('mapRelationDirective', () => { it('should fail if @relation interface is input type', () => { expect(() => transform(gql` - interface Entity { + extend interface Entity { owners: Connection @relation(name: "ownedBy", nodeType: "OwnerInput") } input OwnerInput { @@ -158,7 +159,7 @@ describe('mapRelationDirective', () => { it('should fail if Connection type is in a list', () => { expect(() => transform(gql` - interface Entity { + extend interface Entity { owners: [Connection] @relation(name: "ownedBy", nodeType: "Owner") } interface Owner { @@ -173,7 +174,7 @@ describe('mapRelationDirective', () => { it('should fail if Connection has arguments are not valid types', () => { expect(() => transform(gql` - interface Entity { + extend interface Entity { owners(first: String!, after: Int!): Connection @relation(name: "ownedBy", nodeType: "Owner") } @@ -189,7 +190,7 @@ describe('mapRelationDirective', () => { it('should fail if @relation and @field are used on the same field', () => { expect(() => transform(gql` - interface Entity { + extend interface Entity { owners: Connection @relation(name: "ownedBy", nodeType: "Owner") @field(at: "name") diff --git a/plugins/graphql-backend-module-catalog/src/resolvers.ts b/plugins/graphql-backend-module-catalog/src/resolvers.ts new file mode 100644 index 0000000000..b03d8d6b41 --- /dev/null +++ b/plugins/graphql-backend-module-catalog/src/resolvers.ts @@ -0,0 +1,393 @@ +import _ from 'lodash'; +import { encodeId } from '@frontside/hydraphql'; +import { Resolvers } from 'graphql-modules'; +import { CATALOG_SOURCE } from './constants'; +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { CatalogApi } from '@backstage/catalog-client'; +import { Connection } from 'graphql-relay'; +import { encodeEntityId } from './helpers'; +import { getDirective } from '@graphql-tools/utils'; +import { + GraphQLCompositeType, + GraphQLSchema, + getNamedType, + isCompositeType, + isInterfaceType, + isUnionType, +} from 'graphql'; +import { + EntityRawFilterExpression, + EntityRawFilterField, + OrderDirection, + QueryEntitiesArgs, +} from './__generated__/graphql'; + +function parseEntityFilter(filter: EntityRawFilterExpression[]) { + return { anyOf: filter.map(({ fields }) => ({ allOf: fields })) }; +} + +function traverseFieldDirectives( + type: GraphQLCompositeType, + schema: GraphQLSchema, + { isNested = false }: { isNested?: boolean } = {}, +) { + const fieldMap = new Map }>(); + if (isUnionType(type)) { + type.getTypes().forEach(subType => { + traverseFieldDirectives(subType, schema, { isNested }).forEach( + (mappedFieldNames, childFieldName) => { + const mappedFields = [ + ...(fieldMap.get(childFieldName)?.fields ?? []), + ...mappedFieldNames.fields, + ]; + fieldMap.set(childFieldName, { + isLeaf: fieldMap.get(childFieldName)?.isLeaf ?? false, + fields: new Set(mappedFields), + }); + }, + ); + }); + } else { + if (isInterfaceType(type)) { + const { interfaces, objects } = schema.getImplementations(type); + [...interfaces, ...objects].forEach(subType => { + traverseFieldDirectives(subType, schema, { isNested }).forEach( + (mappedFieldNames, childFieldName) => { + const mappedFields = [ + ...(fieldMap.get(childFieldName)?.fields ?? []), + ...mappedFieldNames.fields, + ]; + fieldMap.set(childFieldName, { + isLeaf: fieldMap.get(childFieldName)?.isLeaf ?? false, + fields: new Set(mappedFields), + }); + }, + ); + }); + } + Object.entries(type.getFields()).forEach(([fieldName, field]) => { + const [fieldDirective] = getDirective(schema, field, 'field') ?? []; + if (!fieldDirective && !isNested) return; + + const unwrappedType = getNamedType(field.type); + + const mappedFieldName = fieldDirective?.at ?? fieldName; + const fields = (fieldMap.get(fieldName)?.fields ?? new Set()).add( + Array.isArray(mappedFieldName) + ? mappedFieldName.join('.') + : mappedFieldName, + ); + + if (isCompositeType(unwrappedType)) { + fieldMap.set(fieldName, { isLeaf: false, fields }); + traverseFieldDirectives(unwrappedType, schema, { + isNested: true, + }).forEach((mappedFieldNames, childFieldName) => { + const childFieldPath = `${fieldName}.${childFieldName}`; + const mappedFields = [ + ...(fieldMap.get(childFieldPath)?.fields ?? []), + ...[...mappedFieldNames.fields].flatMap(childMappedField => + [...(fieldMap.get(fieldName)?.fields ?? [])].map( + parentMappedFieldName => + `${parentMappedFieldName}.${childMappedField}`, + ), + ), + ]; + fieldMap.set(childFieldPath, { + isLeaf: fieldMap.get(childFieldName)?.isLeaf ?? false, + fields: new Set(mappedFields), + }); + }); + } else { + fieldMap.set(fieldName, { isLeaf: true, fields }); + } + }); + } + + return fieldMap; +} + +function mapMatchFilterToQueryFilter( + match: Record>, + fieldMap: Map }>, + parentKey?: string, +): (EntityRawFilterField | { anyOf: EntityRawFilterField[] })[] { + if (parentKey && fieldMap.get(parentKey)?.isLeaf) { + const { values, fields } = match; + const fieldKeys = [...(fieldMap.get(parentKey)?.fields ?? [])]; + return [ + ...(Array.isArray(values) + ? [ + { + anyOf: [ + ...fieldKeys.map(fieldKey => ({ key: fieldKey, values })), + ], + }, + ] + : []), + ...(fields + ? mapMatchFilterToQueryFilter( + fields as Record, + fieldMap, + parentKey, + ) + : []), + ]; + } + return Object.entries(match).reduce((filters, [fieldName, fieldValues]) => { + const matchKey = parentKey ? `${parentKey}.${fieldName}` : fieldName; + if (Array.isArray(fieldValues)) { + const fieldKeys = [...(fieldMap.get(matchKey)?.fields ?? [])]; + return [ + ...filters, + { + anyOf: [ + ...fieldKeys.map(fieldKey => ({ + key: fieldKey, + values: fieldValues, + })), + ], + }, + ]; + } + + return [ + ...filters, + ...mapMatchFilterToQueryFilter(fieldValues, fieldMap, matchKey), + ]; + }, [] as (EntityRawFilterField | { anyOf: EntityRawFilterField[] })[]); +} + +function mapOrderFieldsToQueryOrder( + orders: Record< + string, + | OrderDirection + | { order?: OrderDirection; fields: Record[] } + | Record[] + >[], + fieldMap: Map }>, + parentKey?: string, +): { field: string; order: string }[] { + return orders.flatMap(fieldOrder => { + if (Object.keys(fieldOrder).length > 1) { + throw new Error('Cannot have more than one field in order field object'); + } + const [[fieldName, directionOrChild]] = Object.entries(fieldOrder); + const orderKey = parentKey ? `${parentKey}.${fieldName}` : fieldName; + if (typeof directionOrChild === 'string') { + return [...(fieldMap.get(orderKey)?.fields ?? [])].map(fieldKey => ({ + field: fieldKey, + order: directionOrChild.toLowerCase(), + })); + } + if (Array.isArray(directionOrChild)) { + return [ + ...mapOrderFieldsToQueryOrder(directionOrChild, fieldMap, orderKey), + ]; + } + const { fields, order } = directionOrChild; + if (fields && order) { + throw new Error( + 'Cannot have both "fields" and "order" in order field object', + ); + } + if (fields) { + return mapOrderFieldsToQueryOrder(fields, fieldMap, orderKey); + } + if (order) { + return [...(fieldMap.get(orderKey)?.fields ?? [])].map(fieldKey => ({ + field: fieldKey, + order: order.toLowerCase(), + })); + } + return []; + }); +} + +function mapSearchFilterToTextSearch( + search: Record>, + fieldMap: Map }>, + parentKey?: string, +): string[] { + if (parentKey && fieldMap.get(parentKey)?.isLeaf) { + const { include, fields } = search; + return [ + ...(include + ? fieldMap.get(parentKey)?.fields ?? [] + : []), + ...(fields + ? mapSearchFilterToTextSearch(fields as Record, fieldMap, parentKey) + : []), + ]; + } + return Object.entries(search).flatMap(([fieldName, value]) => { + const searchKey = parentKey ? `${parentKey}.${fieldName}` : fieldName; + if (value === true) { + return [...(fieldMap.get(fieldName)?.fields ?? [])]; + } + if (typeof value === 'object') { + return [...mapSearchFilterToTextSearch(value, fieldMap, searchKey)]; + } + return []; + }); +} + +// TODO Handle labels and annotations separately +export const queryResolvers: () => Resolvers = () => { + let fieldMap: Map }> | null = null + + return { + entity: ( + _root: any, + { + name, + kind, + namespace = 'default', + }: { name: string; kind: string; namespace: string }, + ): { id: string } => ({ + id: encodeId({ + source: CATALOG_SOURCE, + typename: 'Entity', + query: { ref: stringifyEntityRef({ name, kind, namespace }) }, + }), + }), + entities: async ( + _root: any, + { first, after, last, before, filter, rawFilter }: QueryEntitiesArgs, + { catalog }: { catalog: CatalogApi }, + { schema }: { schema: GraphQLSchema }, + ): Promise> => { + if (filter && rawFilter) { + throw new Error( + 'Both "filter" and "rawFilter" arguments cannot be used together', + ); + } + + if (!fieldMap) { + fieldMap = traverseFieldDirectives( + schema.getType('Entity') as GraphQLCompositeType, + schema, + ); + } + + const orderFields = (() => { + if (rawFilter?.orderFields) { + return rawFilter.orderFields.map(({ field, order }) => ({ + field, + order: order.toLowerCase(), + })) + } + if (filter?.order) { + return mapOrderFieldsToQueryOrder( + filter.order as Record[], + fieldMap, + ) + } + return [{ field: 'metadata.uid', order: 'asc' }] + })() + + const fullTextSearch = (() => { + if (rawFilter?.fullTextFilter) { + return { + term: rawFilter.fullTextFilter.term, + fields: rawFilter.fullTextFilter.fields ?? undefined, + } + } + if (filter?.search) { + return { + term: filter.search.term, + fields: filter.search.fields ? mapSearchFilterToTextSearch( + filter.search.fields as Record, + fieldMap, + ) : undefined, + } + } + return { term: '' } + })() + + const queryFilter = (() => { + if (rawFilter?.filter) { + return parseEntityFilter(rawFilter.filter) + } + if (filter?.match) { + return { + anyOf: filter.match.map(match => ({ + allOf: mapMatchFilterToQueryFilter( + match as Record, + fieldMap!, + ), + })), + } + } + return undefined + })() + + const decodedCursor = (c => + c ? JSON.parse(Buffer.from(c, 'base64').toString('utf8')) : undefined)( + after ?? before, + ); + + const cursorParams = { + orderFields, + fullTextSearch, + filter: queryFilter, + }; + const cursor = Buffer.from( + JSON.stringify({ + orderFieldValues: [], + ...cursorParams, + ...decodedCursor, + isPrevious: first === null && last !== null, + }), + 'utf8', + ).toString('base64'); + + let limit: number | undefined = first ?? last ?? undefined; + if (after) limit = first ?? undefined; + if (before) limit = last ?? undefined; + + const orderField = cursorParams.orderFields[0]?.field; + const { items, pageInfo, totalItems } = await catalog.queryEntities({ + fields: [ + 'metadata.uid', + 'metadata.name', + 'metadata.namespace', + 'kind', + ...(orderField ? [orderField] : []), + ], + cursor, + limit: limit ?? undefined, + }); + + // TODO Reuse field's resolvers + return { + edges: items.map(item => ({ + cursor: Buffer.from( + JSON.stringify({ + totalItems, + firstSortFieldValues: [ + orderField ? _.get(items[0], orderField) : items[0].metadata.uid, + items[0].metadata.uid, + ], + ...cursorParams, + ...decodedCursor, + orderFieldValues: [ + orderField ? _.get(item, orderField) : item.metadata.uid, + item.metadata.uid, + ], + }), + 'utf8', + ).toString('base64'), + node: { id: encodeEntityId(item) }, + })), + pageInfo: { + startCursor: pageInfo.prevCursor ?? null, + endCursor: pageInfo.nextCursor ?? null, + hasPreviousPage: Boolean(pageInfo.prevCursor), + hasNextPage: Boolean(pageInfo.nextCursor), + }, + count: totalItems, + } as Connection<{ id: string }>; + }, + } +}; diff --git a/plugins/graphql-backend-node/package.json b/plugins/graphql-backend-node/package.json index 0598a22b21..ece9af7721 100644 --- a/plugins/graphql-backend-node/package.json +++ b/plugins/graphql-backend-node/package.json @@ -34,7 +34,7 @@ }, "dependencies": { "@backstage/backend-plugin-api": "^0.6.7", - "@frontside/hydraphql": "^0.1.2", + "@frontside/hydraphql": "^0.1.3", "dataloader": "^2.1.0", "graphql-modules": "^2.3.0", "graphql-yoga": "^4.0.3" diff --git a/plugins/graphql-backend/package.json b/plugins/graphql-backend/package.json index 63e5abf96a..8c95eafe19 100644 --- a/plugins/graphql-backend/package.json +++ b/plugins/graphql-backend/package.json @@ -39,7 +39,7 @@ "@envelop/dataloader": "^5.0.0", "@envelop/graphql-modules": "^5.0.0", "@frontside/backstage-plugin-graphql-backend-node": "^0.1.4", - "@frontside/hydraphql": "^0.1.2", + "@frontside/hydraphql": "^0.1.3", "dataloader": "^2.1.0", "express": "^4.17.1", "express-promise-router": "^4.1.0", diff --git a/yarn.lock b/yarn.lock index 2c9a9cce7a..4d787909a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4719,10 +4719,10 @@ graphql "16.5.0" hash.js "1.1.7" -"@frontside/hydraphql@^0.1.2": - version "0.1.2" - resolved "https://registry.yarnpkg.com/@frontside/hydraphql/-/hydraphql-0.1.2.tgz#15b68f1b507d5edecc2f69161d5b2e8ab52f0d84" - integrity sha512-DD7+YG1zO0aHQp6K8bFUSKrj5kzlj0f8sOou6oQxSHxzjsggRiFis41ejeUxP/c6QwXns3tgxJfYFW1U3gdRUw== +"@frontside/hydraphql@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@frontside/hydraphql/-/hydraphql-0.1.3.tgz#11107a050f78d096d2b69327b7753c586d1b7e17" + integrity sha512-frXzM8452J3sXwV182XtRNyBdgnCpJe2voUFZk3NQq2thz5D+IDoFK4yXa7MsO9JqmzOND23SPQJmFpIdBvM6w== dependencies: "@graphql-tools/code-file-loader" "^8.0.0" "@graphql-tools/graphql-file-loader" "^8.0.0" From 18b05453d5b7cea662d9abcd3d60bf9cafcf60b8 Mon Sep 17 00:00:00 2001 From: Dmitriy Lazarev Date: Fri, 23 Feb 2024 13:35:46 +0400 Subject: [PATCH 2/8] Rename `generate:types` to `generate` for catalog graphql module Signed-off-by: Dmitriy Lazarev --- plugins/graphql-backend-module-catalog/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/graphql-backend-module-catalog/package.json b/plugins/graphql-backend-module-catalog/package.json index 4f79adfb14..71223cdd03 100644 --- a/plugins/graphql-backend-module-catalog/package.json +++ b/plugins/graphql-backend-module-catalog/package.json @@ -36,7 +36,7 @@ "test": "backstage-cli package test", "prepack": "backstage-cli package prepack", "postpack": "backstage-cli package postpack", - "generate:types": "graphql-codegen -r ts-node/register", + "generate": "graphql-codegen -r ts-node/register", "clean": "backstage-cli package clean" }, "dependencies": { From 07f0624bad84fb155fa0b94b25d0d47a8a3aba8b Mon Sep 17 00:00:00 2001 From: Dmitriy Lazarev Date: Fri, 23 Feb 2024 14:19:36 +0400 Subject: [PATCH 3/8] Fix cyclic type dependency Signed-off-by: Dmitriy Lazarev --- .../src/resolvers.ts | 104 +++++++++++------- 1 file changed, 67 insertions(+), 37 deletions(-) diff --git a/plugins/graphql-backend-module-catalog/src/resolvers.ts b/plugins/graphql-backend-module-catalog/src/resolvers.ts index b03d8d6b41..dffdc217c1 100644 --- a/plugins/graphql-backend-module-catalog/src/resolvers.ts +++ b/plugins/graphql-backend-module-catalog/src/resolvers.ts @@ -15,14 +15,10 @@ import { isInterfaceType, isUnionType, } from 'graphql'; -import { - EntityRawFilterExpression, - EntityRawFilterField, - OrderDirection, - QueryEntitiesArgs, -} from './__generated__/graphql'; -function parseEntityFilter(filter: EntityRawFilterExpression[]) { +type OrderDirection = 'ASC' | 'DESC'; + +function parseEntityFilter(filter: { fields: unknown }[]) { return { anyOf: filter.map(({ fields }) => ({ allOf: fields })) }; } @@ -111,7 +107,10 @@ function mapMatchFilterToQueryFilter( match: Record>, fieldMap: Map }>, parentKey?: string, -): (EntityRawFilterField | { anyOf: EntityRawFilterField[] })[] { +): ( + | { key: string; values: unknown[] } + | { anyOf: { key: string; values: unknown[] }[] } +)[] { if (parentKey && fieldMap.get(parentKey)?.isLeaf) { const { values, fields } = match; const fieldKeys = [...(fieldMap.get(parentKey)?.fields ?? [])]; @@ -155,7 +154,7 @@ function mapMatchFilterToQueryFilter( ...filters, ...mapMatchFilterToQueryFilter(fieldValues, fieldMap, matchKey), ]; - }, [] as (EntityRawFilterField | { anyOf: EntityRawFilterField[] })[]); + }, [] as ({ key: string; values: unknown[] } | { anyOf: { key: string; values: unknown[] }[] })[]); } function mapOrderFieldsToQueryOrder( @@ -212,11 +211,13 @@ function mapSearchFilterToTextSearch( if (parentKey && fieldMap.get(parentKey)?.isLeaf) { const { include, fields } = search; return [ - ...(include - ? fieldMap.get(parentKey)?.fields ?? [] - : []), + ...(include ? fieldMap.get(parentKey)?.fields ?? [] : []), ...(fields - ? mapSearchFilterToTextSearch(fields as Record, fieldMap, parentKey) + ? mapSearchFilterToTextSearch( + fields as Record, + fieldMap, + parentKey, + ) : []), ]; } @@ -234,7 +235,8 @@ function mapSearchFilterToTextSearch( // TODO Handle labels and annotations separately export const queryResolvers: () => Resolvers = () => { - let fieldMap: Map }> | null = null + let fieldMap: Map }> | null = + null; return { entity: ( @@ -253,7 +255,29 @@ export const queryResolvers: () => Resolvers = () => { }), entities: async ( _root: any, - { first, after, last, before, filter, rawFilter }: QueryEntitiesArgs, + { + first, + after, + last, + before, + filter, + rawFilter, + }: { + first?: number; + after?: string; + last?: number; + before: string; + filter: { + match?: Record[]; + order?: Record[]; + search?: { term: string, fields: Record}; + }; + rawFilter: { + filter?: { fields: unknown[] }[]; + orderFields?: { field: string; order: OrderDirection }[]; + fullTextFilter?: { term: string; fields?: string[] }; + }; + }, { catalog }: { catalog: CatalogApi }, { schema }: { schema: GraphQLSchema }, ): Promise> => { @@ -272,55 +296,59 @@ export const queryResolvers: () => Resolvers = () => { const orderFields = (() => { if (rawFilter?.orderFields) { - return rawFilter.orderFields.map(({ field, order }) => ({ - field, - order: order.toLowerCase(), - })) + return rawFilter.orderFields.map( + ({ field, order }: { field: string; order: OrderDirection }) => ({ + field, + order: order.toLowerCase(), + }), + ); } if (filter?.order) { return mapOrderFieldsToQueryOrder( filter.order as Record[], fieldMap, - ) + ); } - return [{ field: 'metadata.uid', order: 'asc' }] - })() + return [{ field: 'metadata.uid', order: 'asc' }]; + })(); const fullTextSearch = (() => { if (rawFilter?.fullTextFilter) { return { term: rawFilter.fullTextFilter.term, fields: rawFilter.fullTextFilter.fields ?? undefined, - } + }; } if (filter?.search) { return { term: filter.search.term, - fields: filter.search.fields ? mapSearchFilterToTextSearch( - filter.search.fields as Record, - fieldMap, - ) : undefined, - } + fields: filter.search.fields + ? mapSearchFilterToTextSearch( + filter.search.fields as Record, + fieldMap, + ) + : undefined, + }; } - return { term: '' } - })() + return { term: '' }; + })(); const queryFilter = (() => { if (rawFilter?.filter) { - return parseEntityFilter(rawFilter.filter) + return parseEntityFilter(rawFilter.filter); } if (filter?.match) { return { - anyOf: filter.match.map(match => ({ + anyOf: filter.match.map((match: unknown) => ({ allOf: mapMatchFilterToQueryFilter( match as Record, fieldMap!, ), })), - } + }; } - return undefined - })() + return undefined; + })(); const decodedCursor = (c => c ? JSON.parse(Buffer.from(c, 'base64').toString('utf8')) : undefined)( @@ -366,7 +394,9 @@ export const queryResolvers: () => Resolvers = () => { JSON.stringify({ totalItems, firstSortFieldValues: [ - orderField ? _.get(items[0], orderField) : items[0].metadata.uid, + orderField + ? _.get(items[0], orderField) + : items[0].metadata.uid, items[0].metadata.uid, ], ...cursorParams, @@ -389,5 +419,5 @@ export const queryResolvers: () => Resolvers = () => { count: totalItems, } as Connection<{ id: string }>; }, - } + }; }; From 275b511c4177ce471a3a733496aeaa24749509f3 Mon Sep 17 00:00:00 2001 From: Dmitriy Lazarev Date: Fri, 23 Feb 2024 15:11:00 +0400 Subject: [PATCH 4/8] Update snapshots Signed-off-by: Dmitriy Lazarev --- .../src/__snapshots__/codegen.test.ts.snap | 663 +++++++++++++++++- 1 file changed, 651 insertions(+), 12 deletions(-) diff --git a/plugins/graphql-backend-module-catalog/src/__snapshots__/codegen.test.ts.snap b/plugins/graphql-backend-module-catalog/src/__snapshots__/codegen.test.ts.snap index c3a69184cc..56ccc30126 100644 --- a/plugins/graphql-backend-module-catalog/src/__snapshots__/codegen.test.ts.snap +++ b/plugins/graphql-backend-module-catalog/src/__snapshots__/codegen.test.ts.snap @@ -196,6 +196,113 @@ interface Entity implements Node { title: String } +type EntityConnection implements Connection { + count: Int + edges: [EntityEdge!]! + pageInfo: PageInfo! +} + +type EntityEdge implements Edge { + cursor: String! + node: Entity! +} + +""" +{ + order: [ + { fieldA: ASC } + { fieldB: DESC } + { fieldC: [{ fieldD: ASC }] } + { fieldE: { order: ASC } } + { + fieldE: { + fields: [{ fieldF: DESC }, { fieldG: ASC }] + } + } + ] + search: { + term: "substring" + fields: { + fieldA: true + fieldB: true + fieldC: { fieldD: true } + fieldE: { + include: true + fields: { fieldF: true, fieldG: true } + } + } + } + match: [ + { fieldA: ["value1", "value2"], fieldB: ["value3"] } + { fieldC: { fieldD: ["value4"] } } + { + fieldE: { + values: ["value5", "value6"], + fields: { fieldF: ["value7"], fieldG: ["value8"] } + } + } + ] +} +""" +input EntityFilter { + match: [EntityFilterExpression!] + order: [EntityOrderField!] + search: EntityTextFilter +} + +input EntityFilterExpression { + annotations: EntityFilterExpression_Annotations + apiVersion: [JSON!] + definition: [JSON!] + description: [JSON!] + kind: [JSON!] + labels: EntityFilterExpression_Labels + lifecycle: [JSON!] + links: EntityFilterExpression_Links + name: [JSON!] + namespace: [JSON!] + parameters: [JSON!] + presence: [JSON!] + profile: EntityFilterExpression_Profile + steps: EntityFilterExpression_Steps + tags: [JSON!] + target: [JSON!] + targets: [JSON!] + title: [JSON!] + type: [JSON!] +} + +input EntityFilterExpression_Annotations { + key: [JSON!] + value: [JSON!] +} + +input EntityFilterExpression_Labels { + key: [JSON!] + value: [JSON!] +} + +input EntityFilterExpression_Links { + icon: [JSON!] + title: [JSON!] + type: [JSON!] + url: [JSON!] +} + +input EntityFilterExpression_Profile { + displayName: [JSON!] + email: [JSON!] + picture: [JSON!] +} + +input EntityFilterExpression_Steps { + action: [JSON!] + id: [JSON!] + if: [JSON!] + input: [JSON!] + name: [JSON!] +} + type EntityLink { icon: String title: String @@ -203,6 +310,142 @@ type EntityLink { url: String! } +input EntityOrderField { + annotations: [EntityOrderField_Annotations!] + apiVersion: OrderDirection + definition: OrderDirection + description: OrderDirection + kind: OrderDirection + labels: [EntityOrderField_Labels!] + lifecycle: OrderDirection + links: [EntityOrderField_Links!] + name: OrderDirection + namespace: OrderDirection + parameters: OrderDirection + presence: OrderDirection + profile: [EntityOrderField_Profile!] + steps: [EntityOrderField_Steps!] + tags: OrderDirection + target: OrderDirection + targets: OrderDirection + title: OrderDirection + type: OrderDirection +} + +input EntityOrderField_Annotations { + key: OrderDirection + value: OrderDirection +} + +input EntityOrderField_Labels { + key: OrderDirection + value: OrderDirection +} + +input EntityOrderField_Links { + icon: OrderDirection + title: OrderDirection + type: OrderDirection + url: OrderDirection +} + +input EntityOrderField_Profile { + displayName: OrderDirection + email: OrderDirection + picture: OrderDirection +} + +input EntityOrderField_Steps { + action: OrderDirection + id: OrderDirection + if: OrderDirection + input: OrderDirection + name: OrderDirection +} + +input EntityRawFilter { + filter: [EntityRawFilterExpression!] + fullTextFilter: EntityRawTextFilter + orderFields: [EntityRawOrderField!] +} + +input EntityRawFilterExpression { + fields: [EntityRawFilterField!]! +} + +input EntityRawFilterField { + key: String! + values: [JSON!]! +} + +input EntityRawOrderField { + field: String! + order: OrderDirection! +} + +input EntityRawTextFilter { + fields: [String!] + term: String! +} + +input EntityTextFilter { + fields: EntityTextFilterFields + term: String! +} + +input EntityTextFilterFields { + annotations: EntityTextFilterFields_Annotations + apiVersion: Boolean + definition: Boolean + description: Boolean + kind: Boolean + labels: EntityTextFilterFields_Labels + lifecycle: Boolean + links: EntityTextFilterFields_Links + name: Boolean + namespace: Boolean + parameters: Boolean + presence: Boolean + profile: EntityTextFilterFields_Profile + steps: EntityTextFilterFields_Steps + tags: Boolean + target: Boolean + targets: Boolean + title: Boolean + type: Boolean +} + +input EntityTextFilterFields_Annotations { + key: Boolean + value: Boolean +} + +input EntityTextFilterFields_Labels { + key: Boolean + value: Boolean +} + +input EntityTextFilterFields_Links { + icon: Boolean + title: Boolean + type: Boolean + url: Boolean +} + +input EntityTextFilterFields_Profile { + displayName: Boolean + email: Boolean + picture: Boolean +} + +input EntityTextFilterFields_Steps { + action: Boolean + id: Boolean + if: Boolean + input: Boolean + name: Boolean +} + type FileLocation implements Entity & Location & Node { annotations: [KeyValuePair!] apiVersion: String! @@ -516,6 +759,11 @@ type OpenAPI implements API & Entity & Node & Ownable { type: String! } +enum OrderDirection { + ASC + DESC +} + type Organization implements Entity & Group & Node { annotations: [KeyValuePair!] apiVersion: String! @@ -561,6 +809,7 @@ type PageInfo { } type Query { + entities(after: String, before: String, filter: EntityFilter, first: Int, last: Int, rawFilter: EntityRawFilter): EntityConnection entity(kind: String!, name: String!, namespace: String): Entity node(id: ID!): Node nodes(ids: [ID!]!): [Node]! @@ -620,6 +869,24 @@ type Service implements Component & Dependency & Entity & Node & Ownable { type: String! } +type ServiceTemplate implements Entity & Node & Ownable & Template { + annotations: [KeyValuePair!] + apiVersion: String! + description: String + id: ID! + kind: String! + labels: [KeyValuePair!] + links: [EntityLink!] + name: String! + namespace: String! + owner: Owner + parameters: JSONObject + steps: [Step!]! + tags: [String!] + title: String + type: String! +} + type Step { action: String! id: String @@ -1135,6 +1402,115 @@ export type Entity = { title?: Maybe; }; +export type EntityConnection = Connection & { + __typename?: 'EntityConnection'; + count?: Maybe; + edges: Array; + pageInfo: PageInfo; +}; + +export type EntityEdge = Edge & { + __typename?: 'EntityEdge'; + cursor: Scalars['String']['output']; + node: Entity; +}; + +/** + * { + * order: [ + * { fieldA: ASC } + * { fieldB: DESC } + * { fieldC: [{ fieldD: ASC }] } + * { fieldE: { order: ASC } } + * { + * fieldE: { + * fields: [{ fieldF: DESC }, { fieldG: ASC }] + * } + * } + * ] + * search: { + * term: "substring" + * fields: { + * fieldA: true + * fieldB: true + * fieldC: { fieldD: true } + * fieldE: { + * include: true + * fields: { fieldF: true, fieldG: true } + * } + * } + * } + * match: [ + * { fieldA: ["value1", "value2"], fieldB: ["value3"] } + * { fieldC: { fieldD: ["value4"] } } + * { + * fieldE: { + * values: ["value5", "value6"], + * fields: { fieldF: ["value7"], fieldG: ["value8"] } + * } + * } + * ] + * } + */ +export type EntityFilter = { + match?: InputMaybe>; + order?: InputMaybe>; + search?: InputMaybe; +}; + +export type EntityFilterExpression = { + annotations?: InputMaybe; + apiVersion?: InputMaybe>; + definition?: InputMaybe>; + description?: InputMaybe>; + kind?: InputMaybe>; + labels?: InputMaybe; + lifecycle?: InputMaybe>; + links?: InputMaybe; + name?: InputMaybe>; + namespace?: InputMaybe>; + parameters?: InputMaybe>; + presence?: InputMaybe>; + profile?: InputMaybe; + steps?: InputMaybe; + tags?: InputMaybe>; + target?: InputMaybe>; + targets?: InputMaybe>; + title?: InputMaybe>; + type?: InputMaybe>; +}; + +export type EntityFilterExpression_Annotations = { + key?: InputMaybe>; + value?: InputMaybe>; +}; + +export type EntityFilterExpression_Labels = { + key?: InputMaybe>; + value?: InputMaybe>; +}; + +export type EntityFilterExpression_Links = { + icon?: InputMaybe>; + title?: InputMaybe>; + type?: InputMaybe>; + url?: InputMaybe>; +}; + +export type EntityFilterExpression_Profile = { + displayName?: InputMaybe>; + email?: InputMaybe>; + picture?: InputMaybe>; +}; + +export type EntityFilterExpression_Steps = { + action?: InputMaybe>; + id?: InputMaybe>; + if?: InputMaybe>; + input?: InputMaybe>; + name?: InputMaybe>; +}; + export type EntityLink = { __typename?: 'EntityLink'; icon?: Maybe; @@ -1143,6 +1519,142 @@ export type EntityLink = { url: Scalars['String']['output']; }; +export type EntityOrderField = { + annotations?: InputMaybe>; + apiVersion?: InputMaybe; + definition?: InputMaybe; + description?: InputMaybe; + kind?: InputMaybe; + labels?: InputMaybe>; + lifecycle?: InputMaybe; + links?: InputMaybe>; + name?: InputMaybe; + namespace?: InputMaybe; + parameters?: InputMaybe; + presence?: InputMaybe; + profile?: InputMaybe>; + steps?: InputMaybe>; + tags?: InputMaybe; + target?: InputMaybe; + targets?: InputMaybe; + title?: InputMaybe; + type?: InputMaybe; +}; + +export type EntityOrderField_Annotations = { + key?: InputMaybe; + value?: InputMaybe; +}; + +export type EntityOrderField_Labels = { + key?: InputMaybe; + value?: InputMaybe; +}; + +export type EntityOrderField_Links = { + icon?: InputMaybe; + title?: InputMaybe; + type?: InputMaybe; + url?: InputMaybe; +}; + +export type EntityOrderField_Profile = { + displayName?: InputMaybe; + email?: InputMaybe; + picture?: InputMaybe; +}; + +export type EntityOrderField_Steps = { + action?: InputMaybe; + id?: InputMaybe; + if?: InputMaybe; + input?: InputMaybe; + name?: InputMaybe; +}; + +export type EntityRawFilter = { + filter?: InputMaybe>; + fullTextFilter?: InputMaybe; + orderFields?: InputMaybe>; +}; + +export type EntityRawFilterExpression = { + fields: Array; +}; + +export type EntityRawFilterField = { + key: Scalars['String']['input']; + values: Array; +}; + +export type EntityRawOrderField = { + field: Scalars['String']['input']; + order: OrderDirection; +}; + +export type EntityRawTextFilter = { + fields?: InputMaybe>; + term: Scalars['String']['input']; +}; + +export type EntityTextFilter = { + fields?: InputMaybe; + term: Scalars['String']['input']; +}; + +export type EntityTextFilterFields = { + annotations?: InputMaybe; + apiVersion?: InputMaybe; + definition?: InputMaybe; + description?: InputMaybe; + kind?: InputMaybe; + labels?: InputMaybe; + lifecycle?: InputMaybe; + links?: InputMaybe; + name?: InputMaybe; + namespace?: InputMaybe; + parameters?: InputMaybe; + presence?: InputMaybe; + profile?: InputMaybe; + steps?: InputMaybe; + tags?: InputMaybe; + target?: InputMaybe; + targets?: InputMaybe; + title?: InputMaybe; + type?: InputMaybe; +}; + +export type EntityTextFilterFields_Annotations = { + key?: InputMaybe; + value?: InputMaybe; +}; + +export type EntityTextFilterFields_Labels = { + key?: InputMaybe; + value?: InputMaybe; +}; + +export type EntityTextFilterFields_Links = { + icon?: InputMaybe; + title?: InputMaybe; + type?: InputMaybe; + url?: InputMaybe; +}; + +export type EntityTextFilterFields_Profile = { + displayName?: InputMaybe; + email?: InputMaybe; + picture?: InputMaybe; +}; + +export type EntityTextFilterFields_Steps = { + action?: InputMaybe; + id?: InputMaybe; + if?: InputMaybe; + input?: InputMaybe; + name?: InputMaybe; +}; + export type FileLocation = Entity & Location & Node & { __typename?: 'FileLocation'; annotations?: Maybe>; @@ -1670,6 +2182,10 @@ export type OpenApiApiProvidedByArgs = { last?: InputMaybe; }; +export type OrderDirection = + | 'ASC' + | 'DESC'; + export type Organization = Entity & Group & Node & { __typename?: 'Organization'; annotations?: Maybe>; @@ -1744,12 +2260,23 @@ export type PageInfo = { export type Query = { __typename?: 'Query'; + entities?: Maybe; entity?: Maybe; node?: Maybe; nodes: Array>; }; +export type QueryEntitiesArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + rawFilter?: InputMaybe; +}; + + export type QueryEntityArgs = { kind: Scalars['String']['input']; name: Scalars['String']['input']; @@ -1879,6 +2406,25 @@ export type ServiceProvidesApisArgs = { last?: InputMaybe; }; +export type ServiceTemplate = Entity & Node & Ownable & Template & { + __typename?: 'ServiceTemplate'; + annotations?: Maybe>; + apiVersion: Scalars['String']['output']; + description?: Maybe; + id: Scalars['ID']['output']; + kind: Scalars['String']['output']; + labels?: Maybe>; + links?: Maybe>; + name: Scalars['String']['output']; + namespace: Scalars['String']['output']; + owner?: Maybe; + parameters?: Maybe; + steps: Array; + tags?: Maybe>; + title?: Maybe; + type: Scalars['String']['output']; +}; + export type Step = { __typename?: 'Step'; action: Scalars['String']['output']; @@ -2267,16 +2813,16 @@ export type ResolversUnionTypes> = { export type ResolversInterfaceTypes> = { API: ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ); Component: ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ); - Connection: ( ApiConnection ) | ( ComponentConnection ) | ( DependencyConnection ) | ( GroupConnection ) | ( OwnableConnection ) | ( ResourceConnection ) | ( SystemConnection ) | ( UserConnection ); + Connection: ( ApiConnection ) | ( ComponentConnection ) | ( DependencyConnection ) | ( EntityConnection ) | ( GroupConnection ) | ( OwnableConnection ) | ( ResourceConnection ) | ( SystemConnection ) | ( UserConnection ); Dependency: ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ); - Edge: ( ApiEdge ) | ( ComponentEdge ) | ( DependencyEdge ) | ( GroupEdge ) | ( OwnableEdge ) | ( ResourceEdge ) | ( SystemEdge ) | ( UserEdge ); - Entity: ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Department ) | ( Omit & { owner: RefType['Owner'] } ) | ( FileLocation ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( OpaqueEntity ) | ( OpaqueGroup ) | ( OpaqueLocation ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner?: Maybe } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Organization ) | ( Omit & { owner: RefType['Owner'] } ) | ( SubDepartment ) | ( Omit & { owner: RefType['Owner'] } ) | ( Team ) | ( UrlLocation ) | ( User ) | ( Omit & { owner: RefType['Owner'] } ); + Edge: ( ApiEdge ) | ( ComponentEdge ) | ( DependencyEdge ) | ( EntityEdge ) | ( GroupEdge ) | ( OwnableEdge ) | ( ResourceEdge ) | ( SystemEdge ) | ( UserEdge ); + Entity: ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Department ) | ( Omit & { owner: RefType['Owner'] } ) | ( FileLocation ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( OpaqueEntity ) | ( OpaqueGroup ) | ( OpaqueLocation ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner?: Maybe } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Organization ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner?: Maybe } ) | ( SubDepartment ) | ( Omit & { owner: RefType['Owner'] } ) | ( Team ) | ( UrlLocation ) | ( User ) | ( Omit & { owner: RefType['Owner'] } ); Group: ( Department ) | ( OpaqueGroup ) | ( Organization ) | ( SubDepartment ) | ( Team ); Location: ( FileLocation ) | ( OpaqueLocation ) | ( UrlLocation ); - Node: ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Department ) | ( Omit & { owner: RefType['Owner'] } ) | ( FileLocation ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( OpaqueEntity ) | ( OpaqueGroup ) | ( OpaqueLocation ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner?: Maybe } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Organization ) | ( Omit & { owner: RefType['Owner'] } ) | ( SubDepartment ) | ( Omit & { owner: RefType['Owner'] } ) | ( Team ) | ( UrlLocation ) | ( User ) | ( Omit & { owner: RefType['Owner'] } ); - Ownable: ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner?: Maybe } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ); + Node: ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Department ) | ( Omit & { owner: RefType['Owner'] } ) | ( FileLocation ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( OpaqueEntity ) | ( OpaqueGroup ) | ( OpaqueLocation ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner?: Maybe } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Organization ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner?: Maybe } ) | ( SubDepartment ) | ( Omit & { owner: RefType['Owner'] } ) | ( Team ) | ( UrlLocation ) | ( User ) | ( Omit & { owner: RefType['Owner'] } ); + Ownable: ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner?: Maybe } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner?: Maybe } ) | ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ); Resource: ( Omit & { owner: RefType['Owner'] } ) | ( Omit & { owner: RefType['Owner'] } ); - Template: ( Omit & { owner?: Maybe } ); + Template: ( Omit & { owner?: Maybe } ) | ( Omit & { owner?: Maybe } ); }; /** Mapping between all available schema types and the resolvers types */ @@ -2298,7 +2844,34 @@ export type ResolversTypes = { Domain: ResolverTypeWrapper & { owner: ResolversTypes['Owner'] }>; Edge: ResolverTypeWrapper['Edge']>; Entity: ResolverTypeWrapper['Entity']>; + EntityConnection: ResolverTypeWrapper; + EntityEdge: ResolverTypeWrapper; + EntityFilter: EntityFilter; + EntityFilterExpression: EntityFilterExpression; + EntityFilterExpression_Annotations: EntityFilterExpression_Annotations; + EntityFilterExpression_Labels: EntityFilterExpression_Labels; + EntityFilterExpression_Links: EntityFilterExpression_Links; + EntityFilterExpression_Profile: EntityFilterExpression_Profile; + EntityFilterExpression_Steps: EntityFilterExpression_Steps; EntityLink: ResolverTypeWrapper; + EntityOrderField: EntityOrderField; + EntityOrderField_Annotations: EntityOrderField_Annotations; + EntityOrderField_Labels: EntityOrderField_Labels; + EntityOrderField_Links: EntityOrderField_Links; + EntityOrderField_Profile: EntityOrderField_Profile; + EntityOrderField_Steps: EntityOrderField_Steps; + EntityRawFilter: EntityRawFilter; + EntityRawFilterExpression: EntityRawFilterExpression; + EntityRawFilterField: EntityRawFilterField; + EntityRawOrderField: EntityRawOrderField; + EntityRawTextFilter: EntityRawTextFilter; + EntityTextFilter: EntityTextFilter; + EntityTextFilterFields: EntityTextFilterFields; + EntityTextFilterFields_Annotations: EntityTextFilterFields_Annotations; + EntityTextFilterFields_Labels: EntityTextFilterFields_Labels; + EntityTextFilterFields_Links: EntityTextFilterFields_Links; + EntityTextFilterFields_Profile: EntityTextFilterFields_Profile; + EntityTextFilterFields_Steps: EntityTextFilterFields_Steps; FileLocation: ResolverTypeWrapper; GRPC: ResolverTypeWrapper & { owner: ResolversTypes['Owner'] }>; GraphQL: ResolverTypeWrapper & { owner: ResolversTypes['Owner'] }>; @@ -2322,6 +2895,7 @@ export type ResolversTypes = { OpaqueResource: ResolverTypeWrapper & { owner: ResolversTypes['Owner'] }>; OpaqueTemplate: ResolverTypeWrapper & { owner?: Maybe }>; OpenAPI: ResolverTypeWrapper & { owner: ResolversTypes['Owner'] }>; + OrderDirection: OrderDirection; Organization: ResolverTypeWrapper; Ownable: ResolverTypeWrapper['Ownable']>; OwnableConnection: ResolverTypeWrapper; @@ -2333,6 +2907,7 @@ export type ResolversTypes = { ResourceConnection: ResolverTypeWrapper; ResourceEdge: ResolverTypeWrapper; Service: ResolverTypeWrapper & { owner: ResolversTypes['Owner'] }>; + ServiceTemplate: ResolverTypeWrapper & { owner?: Maybe }>; Step: ResolverTypeWrapper; String: ResolverTypeWrapper; SubDepartment: ResolverTypeWrapper; @@ -2369,7 +2944,34 @@ export type ResolversParentTypes = { Domain: Omit & { owner: ResolversParentTypes['Owner'] }; Edge: ResolversInterfaceTypes['Edge']; Entity: ResolversInterfaceTypes['Entity']; + EntityConnection: EntityConnection; + EntityEdge: EntityEdge; + EntityFilter: EntityFilter; + EntityFilterExpression: EntityFilterExpression; + EntityFilterExpression_Annotations: EntityFilterExpression_Annotations; + EntityFilterExpression_Labels: EntityFilterExpression_Labels; + EntityFilterExpression_Links: EntityFilterExpression_Links; + EntityFilterExpression_Profile: EntityFilterExpression_Profile; + EntityFilterExpression_Steps: EntityFilterExpression_Steps; EntityLink: EntityLink; + EntityOrderField: EntityOrderField; + EntityOrderField_Annotations: EntityOrderField_Annotations; + EntityOrderField_Labels: EntityOrderField_Labels; + EntityOrderField_Links: EntityOrderField_Links; + EntityOrderField_Profile: EntityOrderField_Profile; + EntityOrderField_Steps: EntityOrderField_Steps; + EntityRawFilter: EntityRawFilter; + EntityRawFilterExpression: EntityRawFilterExpression; + EntityRawFilterField: EntityRawFilterField; + EntityRawOrderField: EntityRawOrderField; + EntityRawTextFilter: EntityRawTextFilter; + EntityTextFilter: EntityTextFilter; + EntityTextFilterFields: EntityTextFilterFields; + EntityTextFilterFields_Annotations: EntityTextFilterFields_Annotations; + EntityTextFilterFields_Labels: EntityTextFilterFields_Labels; + EntityTextFilterFields_Links: EntityTextFilterFields_Links; + EntityTextFilterFields_Profile: EntityTextFilterFields_Profile; + EntityTextFilterFields_Steps: EntityTextFilterFields_Steps; FileLocation: FileLocation; GRPC: Omit & { owner: ResolversParentTypes['Owner'] }; GraphQL: Omit & { owner: ResolversParentTypes['Owner'] }; @@ -2404,6 +3006,7 @@ export type ResolversParentTypes = { ResourceConnection: ResourceConnection; ResourceEdge: ResourceEdge; Service: Omit & { owner: ResolversParentTypes['Owner'] }; + ServiceTemplate: Omit & { owner?: Maybe }; Step: Step; String: Scalars['String']['output']; SubDepartment: SubDepartment; @@ -2560,7 +3163,7 @@ export type ComponentEdgeResolvers = { - __resolveType: TypeResolveFn<'APIConnection' | 'ComponentConnection' | 'DependencyConnection' | 'GroupConnection' | 'OwnableConnection' | 'ResourceConnection' | 'SystemConnection' | 'UserConnection', ParentType, ContextType>; + __resolveType: TypeResolveFn<'APIConnection' | 'ComponentConnection' | 'DependencyConnection' | 'EntityConnection' | 'GroupConnection' | 'OwnableConnection' | 'ResourceConnection' | 'SystemConnection' | 'UserConnection', ParentType, ContextType>; count?: Resolver, ParentType, ContextType>; edges?: Resolver, ParentType, ContextType>; pageInfo?: Resolver; @@ -2643,13 +3246,13 @@ export type DomainResolvers = { - __resolveType: TypeResolveFn<'APIEdge' | 'ComponentEdge' | 'DependencyEdge' | 'GroupEdge' | 'OwnableEdge' | 'ResourceEdge' | 'SystemEdge' | 'UserEdge', ParentType, ContextType>; + __resolveType: TypeResolveFn<'APIEdge' | 'ComponentEdge' | 'DependencyEdge' | 'EntityEdge' | 'GroupEdge' | 'OwnableEdge' | 'ResourceEdge' | 'SystemEdge' | 'UserEdge', ParentType, ContextType>; cursor?: Resolver; node?: Resolver; }; export type EntityResolvers = { - __resolveType: TypeResolveFn<'AsyncAPI' | 'Database' | 'Department' | 'Domain' | 'FileLocation' | 'GRPC' | 'GraphQL' | 'Library' | 'OpaqueAPI' | 'OpaqueComponent' | 'OpaqueEntity' | 'OpaqueGroup' | 'OpaqueLocation' | 'OpaqueResource' | 'OpaqueTemplate' | 'OpenAPI' | 'Organization' | 'Service' | 'SubDepartment' | 'System' | 'Team' | 'URLLocation' | 'User' | 'Website', ParentType, ContextType>; + __resolveType: TypeResolveFn<'AsyncAPI' | 'Database' | 'Department' | 'Domain' | 'FileLocation' | 'GRPC' | 'GraphQL' | 'Library' | 'OpaqueAPI' | 'OpaqueComponent' | 'OpaqueEntity' | 'OpaqueGroup' | 'OpaqueLocation' | 'OpaqueResource' | 'OpaqueTemplate' | 'OpenAPI' | 'Organization' | 'Service' | 'ServiceTemplate' | 'SubDepartment' | 'System' | 'Team' | 'URLLocation' | 'User' | 'Website', ParentType, ContextType>; annotations?: Resolver>, ParentType, ContextType>; apiVersion?: Resolver; description?: Resolver, ParentType, ContextType>; @@ -2663,6 +3266,19 @@ export type EntityResolvers, ParentType, ContextType>; }; +export type EntityConnectionResolvers = { + count?: Resolver, ParentType, ContextType>; + edges?: Resolver, ParentType, ContextType>; + pageInfo?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type EntityEdgeResolvers = { + cursor?: Resolver; + node?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type EntityLinkResolvers = { icon?: Resolver, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -2834,7 +3450,7 @@ export type LocationResolvers = { - __resolveType: TypeResolveFn<'AsyncAPI' | 'Database' | 'Department' | 'Domain' | 'FileLocation' | 'GRPC' | 'GraphQL' | 'Library' | 'OpaqueAPI' | 'OpaqueComponent' | 'OpaqueEntity' | 'OpaqueGroup' | 'OpaqueLocation' | 'OpaqueResource' | 'OpaqueTemplate' | 'OpenAPI' | 'Organization' | 'Service' | 'SubDepartment' | 'System' | 'Team' | 'URLLocation' | 'User' | 'Website', ParentType, ContextType>; + __resolveType: TypeResolveFn<'AsyncAPI' | 'Database' | 'Department' | 'Domain' | 'FileLocation' | 'GRPC' | 'GraphQL' | 'Library' | 'OpaqueAPI' | 'OpaqueComponent' | 'OpaqueEntity' | 'OpaqueGroup' | 'OpaqueLocation' | 'OpaqueResource' | 'OpaqueTemplate' | 'OpenAPI' | 'Organization' | 'Service' | 'ServiceTemplate' | 'SubDepartment' | 'System' | 'Team' | 'URLLocation' | 'User' | 'Website', ParentType, ContextType>; id?: Resolver; }; @@ -3023,7 +3639,7 @@ export type OrganizationResolvers = { - __resolveType: TypeResolveFn<'AsyncAPI' | 'Database' | 'Domain' | 'GRPC' | 'GraphQL' | 'Library' | 'OpaqueAPI' | 'OpaqueComponent' | 'OpaqueResource' | 'OpaqueTemplate' | 'OpenAPI' | 'Service' | 'System' | 'Website', ParentType, ContextType>; + __resolveType: TypeResolveFn<'AsyncAPI' | 'Database' | 'Domain' | 'GRPC' | 'GraphQL' | 'Library' | 'OpaqueAPI' | 'OpaqueComponent' | 'OpaqueResource' | 'OpaqueTemplate' | 'OpenAPI' | 'Service' | 'ServiceTemplate' | 'System' | 'Website', ParentType, ContextType>; id?: Resolver; }; @@ -3053,6 +3669,7 @@ export type PageInfoResolvers = { + entities?: Resolver, ParentType, ContextType, Partial>; entity?: Resolver, ParentType, ContextType, RequireFields>; node?: Resolver, ParentType, ContextType, RequireFields>; nodes?: Resolver>, ParentType, ContextType, RequireFields>; @@ -3116,6 +3733,25 @@ export type ServiceResolvers; }; +export type ServiceTemplateResolvers = { + annotations?: Resolver>, ParentType, ContextType>; + apiVersion?: Resolver; + description?: Resolver, ParentType, ContextType>; + id?: Resolver; + kind?: Resolver; + labels?: Resolver>, ParentType, ContextType>; + links?: Resolver>, ParentType, ContextType>; + name?: Resolver; + namespace?: Resolver; + owner?: Resolver, ParentType, ContextType>; + parameters?: Resolver, ParentType, ContextType>; + steps?: Resolver, ParentType, ContextType>; + tags?: Resolver>, ParentType, ContextType>; + title?: Resolver, ParentType, ContextType>; + type?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type StepResolvers = { action?: Resolver; id?: Resolver, ParentType, ContextType>; @@ -3201,7 +3837,7 @@ export type TeamResolvers = { - __resolveType: TypeResolveFn<'OpaqueTemplate', ParentType, ContextType>; + __resolveType: TypeResolveFn<'OpaqueTemplate' | 'ServiceTemplate', ParentType, ContextType>; annotations?: Resolver>, ParentType, ContextType>; apiVersion?: Resolver; description?: Resolver, ParentType, ContextType>; @@ -3322,6 +3958,8 @@ export type Resolvers = { Domain?: DomainResolvers; Edge?: EdgeResolvers; Entity?: EntityResolvers; + EntityConnection?: EntityConnectionResolvers; + EntityEdge?: EntityEdgeResolvers; EntityLink?: EntityLinkResolvers; FileLocation?: FileLocationResolvers; GRPC?: GrpcResolvers; @@ -3355,6 +3993,7 @@ export type Resolvers = { ResourceConnection?: ResourceConnectionResolvers; ResourceEdge?: ResourceEdgeResolvers; Service?: ServiceResolvers; + ServiceTemplate?: ServiceTemplateResolvers; Step?: StepResolvers; SubDepartment?: SubDepartmentResolvers; System?: SystemResolvers; From f4e0574176a3e4760b7c67744d265c99bbe7e331 Mon Sep 17 00:00:00 2001 From: Dmitriy Lazarev Date: Tue, 27 Feb 2024 21:29:05 +0500 Subject: [PATCH 5/8] Fix cursor's `isPrevious` flag Signed-off-by: Dmitriy Lazarev --- .../src/resolvers.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/plugins/graphql-backend-module-catalog/src/resolvers.ts b/plugins/graphql-backend-module-catalog/src/resolvers.ts index dffdc217c1..a3c56a5624 100644 --- a/plugins/graphql-backend-module-catalog/src/resolvers.ts +++ b/plugins/graphql-backend-module-catalog/src/resolvers.ts @@ -365,14 +365,14 @@ export const queryResolvers: () => Resolvers = () => { orderFieldValues: [], ...cursorParams, ...decodedCursor, - isPrevious: first === null && last !== null, + isPrevious: (first === undefined && last !== undefined) || (after === undefined && before !== undefined), }), 'utf8', ).toString('base64'); - let limit: number | undefined = first ?? last ?? undefined; - if (after) limit = first ?? undefined; - if (before) limit = last ?? undefined; + let limit: number | undefined = first ?? last; + if (after) limit = first; + if (before) limit = last; const orderField = cursorParams.orderFields[0]?.field; const { items, pageInfo, totalItems } = await catalog.queryEntities({ @@ -384,7 +384,7 @@ export const queryResolvers: () => Resolvers = () => { ...(orderField ? [orderField] : []), ], cursor, - limit: limit ?? undefined, + limit, }); // TODO Reuse field's resolvers @@ -411,8 +411,8 @@ export const queryResolvers: () => Resolvers = () => { node: { id: encodeEntityId(item) }, })), pageInfo: { - startCursor: pageInfo.prevCursor ?? null, - endCursor: pageInfo.nextCursor ?? null, + startCursor: pageInfo.prevCursor ?? undefined, + endCursor: pageInfo.nextCursor ?? undefined, hasPreviousPage: Boolean(pageInfo.prevCursor), hasNextPage: Boolean(pageInfo.nextCursor), }, From 16c00913d49628c9558b5f8a64a8ddec7d66a806 Mon Sep 17 00:00:00 2001 From: Dmitriy Lazarev Date: Tue, 27 Feb 2024 21:57:44 +0500 Subject: [PATCH 6/8] Transform filters to Catalog's cursor only if `after/before` isn't passed Signed-off-by: Dmitriy Lazarev --- .../src/resolvers.ts | 152 ++++++++++-------- 1 file changed, 84 insertions(+), 68 deletions(-) diff --git a/plugins/graphql-backend-module-catalog/src/resolvers.ts b/plugins/graphql-backend-module-catalog/src/resolvers.ts index a3c56a5624..28a8006ed3 100644 --- a/plugins/graphql-backend-module-catalog/src/resolvers.ts +++ b/plugins/graphql-backend-module-catalog/src/resolvers.ts @@ -18,6 +18,16 @@ import { type OrderDirection = 'ASC' | 'DESC'; +interface CatalogCursor { + firstSortFieldValues?: [string, string]; + orderFieldValues?: [string, string] | never[]; + totalItems?: number; + isPrevious: boolean; + orderFields?: Array<{ field: string; order: 'asc' | 'desc' }>; + fullTextSearch?: { term: string; fields?: string[] }; + filter?: { anyOf: Array<{ allOf: { key: string; values: string[] }[] }> }; +} + function parseEntityFilter(filter: { fields: unknown }[]) { return { anyOf: filter.map(({ fields }) => ({ allOf: fields })) }; } @@ -270,7 +280,7 @@ export const queryResolvers: () => Resolvers = () => { filter: { match?: Record[]; order?: Record[]; - search?: { term: string, fields: Record}; + search?: { term: string; fields: Record }; }; rawFilter: { filter?: { fields: unknown[] }[]; @@ -294,78 +304,85 @@ export const queryResolvers: () => Resolvers = () => { ); } - const orderFields = (() => { - if (rawFilter?.orderFields) { - return rawFilter.orderFields.map( - ({ field, order }: { field: string; order: OrderDirection }) => ({ - field, - order: order.toLowerCase(), - }), - ); - } - if (filter?.order) { - return mapOrderFieldsToQueryOrder( - filter.order as Record[], - fieldMap, - ); - } - return [{ field: 'metadata.uid', order: 'asc' }]; - })(); - - const fullTextSearch = (() => { - if (rawFilter?.fullTextFilter) { - return { - term: rawFilter.fullTextFilter.term, - fields: rawFilter.fullTextFilter.fields ?? undefined, - }; - } - if (filter?.search) { - return { - term: filter.search.term, - fields: filter.search.fields - ? mapSearchFilterToTextSearch( - filter.search.fields as Record, - fieldMap, - ) - : undefined, - }; - } - return { term: '' }; - })(); - - const queryFilter = (() => { - if (rawFilter?.filter) { - return parseEntityFilter(rawFilter.filter); - } - if (filter?.match) { - return { - anyOf: filter.match.map((match: unknown) => ({ - allOf: mapMatchFilterToQueryFilter( - match as Record, - fieldMap!, - ), - })), - }; - } - return undefined; - })(); - const decodedCursor = (c => c ? JSON.parse(Buffer.from(c, 'base64').toString('utf8')) : undefined)( after ?? before, ); - const cursorParams = { - orderFields, - fullTextSearch, - filter: queryFilter, + const cursorObject: Partial = { + orderFieldValues: [], }; + if (decodedCursor) { + Object.assign(cursorObject, decodedCursor); + } else { + const orderFields = (() => { + if (rawFilter?.orderFields) { + return rawFilter.orderFields.map( + ({ field, order }: { field: string; order: OrderDirection }) => ({ + field, + order: order.toLowerCase(), + }), + ); + } + if (filter?.order) { + return mapOrderFieldsToQueryOrder( + filter.order as Record[], + fieldMap, + ); + } + return [{ field: 'metadata.uid', order: 'asc' }]; + })(); + + const fullTextSearch = (() => { + if (rawFilter?.fullTextFilter) { + return { + term: rawFilter.fullTextFilter.term, + fields: rawFilter.fullTextFilter.fields ?? undefined, + }; + } + if (filter?.search) { + return { + term: filter.search.term, + fields: filter.search.fields + ? mapSearchFilterToTextSearch( + filter.search.fields as Record, + fieldMap, + ) + : undefined, + }; + } + return { term: '' }; + })(); + + const queryFilter = (() => { + if (rawFilter?.filter) { + return parseEntityFilter(rawFilter.filter); + } + if (filter?.match) { + return { + anyOf: filter.match.map((match: unknown) => ({ + allOf: mapMatchFilterToQueryFilter( + match as Record, + fieldMap!, + ), + })), + }; + } + return undefined; + })(); + + Object.assign(cursorObject, { + orderFields, + fullTextSearch, + filter: queryFilter, + }); + } const cursor = Buffer.from( JSON.stringify({ - orderFieldValues: [], - ...cursorParams, - ...decodedCursor, - isPrevious: (first === undefined && last !== undefined) || (after === undefined && before !== undefined), + ...cursorObject, + isPrevious: + (first === undefined && last !== undefined) || + (after === undefined && before !== undefined), }), 'utf8', ).toString('base64'); @@ -374,7 +391,7 @@ export const queryResolvers: () => Resolvers = () => { if (after) limit = first; if (before) limit = last; - const orderField = cursorParams.orderFields[0]?.field; + const orderField = cursorObject.orderFields?.[0]?.field; const { items, pageInfo, totalItems } = await catalog.queryEntities({ fields: [ 'metadata.uid', @@ -399,8 +416,7 @@ export const queryResolvers: () => Resolvers = () => { : items[0].metadata.uid, items[0].metadata.uid, ], - ...cursorParams, - ...decodedCursor, + ...cursorObject, orderFieldValues: [ orderField ? _.get(item, orderField) : item.metadata.uid, item.metadata.uid, From 72883ea07167f88f5fef4cf9f58f5d5852e2cd92 Mon Sep 17 00:00:00 2001 From: Dmitriy Lazarev Date: Tue, 27 Feb 2024 23:11:16 +0500 Subject: [PATCH 7/8] Exclude `labels/annotations` fields from filtering Signed-off-by: Dmitriy Lazarev --- .../src/__snapshots__/codegen.test.ts.snap | 91 ++----------------- .../src/catalog/catalog.graphql | 4 +- .../src/common/common.graphql | 1 + .../src/generateInputTypes.ts | 3 +- 4 files changed, 12 insertions(+), 87 deletions(-) diff --git a/plugins/graphql-backend-module-catalog/src/__snapshots__/codegen.test.ts.snap b/plugins/graphql-backend-module-catalog/src/__snapshots__/codegen.test.ts.snap index 56ccc30126..eff4cc325c 100644 --- a/plugins/graphql-backend-module-catalog/src/__snapshots__/codegen.test.ts.snap +++ b/plugins/graphql-backend-module-catalog/src/__snapshots__/codegen.test.ts.snap @@ -5,6 +5,8 @@ exports[`graphql-catalog codegen should generate the correct code: graphql 1`] = directive @discriminationAlias(type: String!, value: String!) repeatable on INTERFACE +directive @excludeFromFilter on FIELD_DEFINITION + directive @field(at: _DirectiveArgument_, default: _DirectiveArgument_) on FIELD_DEFINITION directive @implements(interface: String!) on INTERFACE | OBJECT @@ -251,12 +253,10 @@ input EntityFilter { } input EntityFilterExpression { - annotations: EntityFilterExpression_Annotations apiVersion: [JSON!] definition: [JSON!] description: [JSON!] kind: [JSON!] - labels: EntityFilterExpression_Labels lifecycle: [JSON!] links: EntityFilterExpression_Links name: [JSON!] @@ -272,16 +272,6 @@ input EntityFilterExpression { type: [JSON!] } -input EntityFilterExpression_Annotations { - key: [JSON!] - value: [JSON!] -} - -input EntityFilterExpression_Labels { - key: [JSON!] - value: [JSON!] -} - input EntityFilterExpression_Links { icon: [JSON!] title: [JSON!] @@ -311,12 +301,10 @@ type EntityLink { } input EntityOrderField { - annotations: [EntityOrderField_Annotations!] apiVersion: OrderDirection definition: OrderDirection description: OrderDirection kind: OrderDirection - labels: [EntityOrderField_Labels!] lifecycle: OrderDirection links: [EntityOrderField_Links!] name: OrderDirection @@ -332,16 +320,6 @@ input EntityOrderField { type: OrderDirection } -input EntityOrderField_Annotations { - key: OrderDirection - value: OrderDirection -} - -input EntityOrderField_Labels { - key: OrderDirection - value: OrderDirection -} - input EntityOrderField_Links { icon: OrderDirection title: OrderDirection @@ -394,12 +372,10 @@ input EntityTextFilter { } input EntityTextFilterFields { - annotations: EntityTextFilterFields_Annotations apiVersion: Boolean definition: Boolean description: Boolean kind: Boolean - labels: EntityTextFilterFields_Labels lifecycle: Boolean links: EntityTextFilterFields_Links name: Boolean @@ -415,16 +391,6 @@ input EntityTextFilterFields { type: Boolean } -input EntityTextFilterFields_Annotations { - key: Boolean - value: Boolean -} - -input EntityTextFilterFields_Labels { - key: Boolean - value: Boolean -} - input EntityTextFilterFields_Links { icon: Boolean title: Boolean @@ -1459,12 +1425,10 @@ export type EntityFilter = { }; export type EntityFilterExpression = { - annotations?: InputMaybe; apiVersion?: InputMaybe>; definition?: InputMaybe>; description?: InputMaybe>; kind?: InputMaybe>; - labels?: InputMaybe; lifecycle?: InputMaybe>; links?: InputMaybe; name?: InputMaybe>; @@ -1480,16 +1444,6 @@ export type EntityFilterExpression = { type?: InputMaybe>; }; -export type EntityFilterExpression_Annotations = { - key?: InputMaybe>; - value?: InputMaybe>; -}; - -export type EntityFilterExpression_Labels = { - key?: InputMaybe>; - value?: InputMaybe>; -}; - export type EntityFilterExpression_Links = { icon?: InputMaybe>; title?: InputMaybe>; @@ -1520,12 +1474,10 @@ export type EntityLink = { }; export type EntityOrderField = { - annotations?: InputMaybe>; apiVersion?: InputMaybe; definition?: InputMaybe; description?: InputMaybe; kind?: InputMaybe; - labels?: InputMaybe>; lifecycle?: InputMaybe; links?: InputMaybe>; name?: InputMaybe; @@ -1541,16 +1493,6 @@ export type EntityOrderField = { type?: InputMaybe; }; -export type EntityOrderField_Annotations = { - key?: InputMaybe; - value?: InputMaybe; -}; - -export type EntityOrderField_Labels = { - key?: InputMaybe; - value?: InputMaybe; -}; - export type EntityOrderField_Links = { icon?: InputMaybe; title?: InputMaybe; @@ -1603,12 +1545,10 @@ export type EntityTextFilter = { }; export type EntityTextFilterFields = { - annotations?: InputMaybe; apiVersion?: InputMaybe; definition?: InputMaybe; description?: InputMaybe; kind?: InputMaybe; - labels?: InputMaybe; lifecycle?: InputMaybe; links?: InputMaybe; name?: InputMaybe; @@ -1624,16 +1564,6 @@ export type EntityTextFilterFields = { type?: InputMaybe; }; -export type EntityTextFilterFields_Annotations = { - key?: InputMaybe; - value?: InputMaybe; -}; - -export type EntityTextFilterFields_Labels = { - key?: InputMaybe; - value?: InputMaybe; -}; - export type EntityTextFilterFields_Links = { icon?: InputMaybe; title?: InputMaybe; @@ -2848,15 +2778,11 @@ export type ResolversTypes = { EntityEdge: ResolverTypeWrapper; EntityFilter: EntityFilter; EntityFilterExpression: EntityFilterExpression; - EntityFilterExpression_Annotations: EntityFilterExpression_Annotations; - EntityFilterExpression_Labels: EntityFilterExpression_Labels; EntityFilterExpression_Links: EntityFilterExpression_Links; EntityFilterExpression_Profile: EntityFilterExpression_Profile; EntityFilterExpression_Steps: EntityFilterExpression_Steps; EntityLink: ResolverTypeWrapper; EntityOrderField: EntityOrderField; - EntityOrderField_Annotations: EntityOrderField_Annotations; - EntityOrderField_Labels: EntityOrderField_Labels; EntityOrderField_Links: EntityOrderField_Links; EntityOrderField_Profile: EntityOrderField_Profile; EntityOrderField_Steps: EntityOrderField_Steps; @@ -2867,8 +2793,6 @@ export type ResolversTypes = { EntityRawTextFilter: EntityRawTextFilter; EntityTextFilter: EntityTextFilter; EntityTextFilterFields: EntityTextFilterFields; - EntityTextFilterFields_Annotations: EntityTextFilterFields_Annotations; - EntityTextFilterFields_Labels: EntityTextFilterFields_Labels; EntityTextFilterFields_Links: EntityTextFilterFields_Links; EntityTextFilterFields_Profile: EntityTextFilterFields_Profile; EntityTextFilterFields_Steps: EntityTextFilterFields_Steps; @@ -2948,15 +2872,11 @@ export type ResolversParentTypes = { EntityEdge: EntityEdge; EntityFilter: EntityFilter; EntityFilterExpression: EntityFilterExpression; - EntityFilterExpression_Annotations: EntityFilterExpression_Annotations; - EntityFilterExpression_Labels: EntityFilterExpression_Labels; EntityFilterExpression_Links: EntityFilterExpression_Links; EntityFilterExpression_Profile: EntityFilterExpression_Profile; EntityFilterExpression_Steps: EntityFilterExpression_Steps; EntityLink: EntityLink; EntityOrderField: EntityOrderField; - EntityOrderField_Annotations: EntityOrderField_Annotations; - EntityOrderField_Labels: EntityOrderField_Labels; EntityOrderField_Links: EntityOrderField_Links; EntityOrderField_Profile: EntityOrderField_Profile; EntityOrderField_Steps: EntityOrderField_Steps; @@ -2967,8 +2887,6 @@ export type ResolversParentTypes = { EntityRawTextFilter: EntityRawTextFilter; EntityTextFilter: EntityTextFilter; EntityTextFilterFields: EntityTextFilterFields; - EntityTextFilterFields_Annotations: EntityTextFilterFields_Annotations; - EntityTextFilterFields_Labels: EntityTextFilterFields_Labels; EntityTextFilterFields_Links: EntityTextFilterFields_Links; EntityTextFilterFields_Profile: EntityTextFilterFields_Profile; EntityTextFilterFields_Steps: EntityTextFilterFields_Steps; @@ -3038,6 +2956,10 @@ export type DiscriminationAliasDirectiveArgs = { export type DiscriminationAliasDirectiveResolver = DirectiveResolverFn; +export type ExcludeFromFilterDirectiveArgs = { }; + +export type ExcludeFromFilterDirectiveResolver = DirectiveResolverFn; + export type FieldDirectiveArgs = { at?: Maybe; default?: Maybe; @@ -4013,6 +3935,7 @@ export type Resolvers = { export type DirectiveResolvers = { discriminates?: DiscriminatesDirectiveResolver; discriminationAlias?: DiscriminationAliasDirectiveResolver; + excludeFromFilter?: ExcludeFromFilterDirectiveResolver; field?: FieldDirectiveResolver; implements?: ImplementsDirectiveResolver; relation?: RelationDirectiveResolver; diff --git a/plugins/graphql-backend-module-catalog/src/catalog/catalog.graphql b/plugins/graphql-backend-module-catalog/src/catalog/catalog.graphql index 317588f696..1a8481dac7 100644 --- a/plugins/graphql-backend-module-catalog/src/catalog/catalog.graphql +++ b/plugins/graphql-backend-module-catalog/src/catalog/catalog.graphql @@ -15,8 +15,8 @@ extend interface Entity apiVersion: String! @field(at: "apiVersion") title: String @field(at: "metadata.title") description: String @field(at: "metadata.description") - labels: [KeyValuePair!] @field(at: "metadata.labels") - annotations: [KeyValuePair!] @field(at: "metadata.annotations") + labels: [KeyValuePair!] @field(at: "metadata.labels") @excludeFromFilter + annotations: [KeyValuePair!] @field(at: "metadata.annotations") @excludeFromFilter tags: [String!] @field(at: "metadata.tags") links: [EntityLink!] @field(at: "metadata.links") } diff --git a/plugins/graphql-backend-module-catalog/src/common/common.graphql b/plugins/graphql-backend-module-catalog/src/common/common.graphql index be20b2894c..064c5e5878 100644 --- a/plugins/graphql-backend-module-catalog/src/common/common.graphql +++ b/plugins/graphql-backend-module-catalog/src/common/common.graphql @@ -3,6 +3,7 @@ directive @relation( nodeType: String kind: String ) on FIELD_DEFINITION +directive @excludeFromFilter on FIELD_DEFINITION scalar JSON scalar JSONObject diff --git a/plugins/graphql-backend-module-catalog/src/generateInputTypes.ts b/plugins/graphql-backend-module-catalog/src/generateInputTypes.ts index 1994071724..29c09bff18 100644 --- a/plugins/graphql-backend-module-catalog/src/generateInputTypes.ts +++ b/plugins/graphql-backend-module-catalog/src/generateInputTypes.ts @@ -257,7 +257,8 @@ function processTypes( } Object.entries(type.getFields()).forEach(([fieldName, field]) => { const [fieldDirective] = getDirective(schema, field, 'field') ?? []; - if (!fieldDirective && !isNested) return; + const [excludeFromFilter] = getDirective(schema, field, 'excludeFromFilter') ?? [] + if (!fieldDirective && !isNested || excludeFromFilter) return; // const sourceFieldName = fieldDirective.at ?? fieldName From 82f7c78a5c69a9162c1216d5214ed08521eb6719 Mon Sep 17 00:00:00 2001 From: Dmitriy Lazarev Date: Thu, 29 Feb 2024 16:05:13 +0500 Subject: [PATCH 8/8] Support filter loose typed fields Signed-off-by: Dmitriy Lazarev --- .../src/__snapshots__/codegen.test.ts.snap | 235 +++++++- .../src/catalog/catalog.graphql | 18 +- .../src/catalog/catalog.ts | 11 +- .../src/common/common.graphql | 5 +- .../src/generateInputTypes.test.ts | 152 +++-- .../src/generateInputTypes.ts | 546 ++++++++++++++---- .../src/resolvers.ts | 145 ++++- 7 files changed, 900 insertions(+), 212 deletions(-) diff --git a/plugins/graphql-backend-module-catalog/src/__snapshots__/codegen.test.ts.snap b/plugins/graphql-backend-module-catalog/src/__snapshots__/codegen.test.ts.snap index eff4cc325c..79ac4800d2 100644 --- a/plugins/graphql-backend-module-catalog/src/__snapshots__/codegen.test.ts.snap +++ b/plugins/graphql-backend-module-catalog/src/__snapshots__/codegen.test.ts.snap @@ -5,8 +5,6 @@ exports[`graphql-catalog codegen should generate the correct code: graphql 1`] = directive @discriminationAlias(type: String!, value: String!) repeatable on INTERFACE -directive @excludeFromFilter on FIELD_DEFINITION - directive @field(at: _DirectiveArgument_, default: _DirectiveArgument_) on FIELD_DEFINITION directive @implements(interface: String!) on INTERFACE | OBJECT @@ -15,6 +13,8 @@ directive @relation(kind: String, name: String, nodeType: String) on FIELD_DEFIN directive @resolve(at: _DirectiveArgument_, from: String, nodeType: String) on FIELD_DEFINITION +directive @sourceType(name: String!) on FIELD_DEFINITION + interface API implements Entity & Node & Ownable { annotations: [KeyValuePair!] apiConsumedBy(after: String, before: String, first: Int, last: Int): ComponentConnection @@ -30,6 +30,7 @@ interface API implements Entity & Node & Ownable { name: String! namespace: String! owner: Owner! + relations: [Relation!] system: System tags: [String!] title: String @@ -62,6 +63,7 @@ type AsyncAPI implements API & Entity & Node & Ownable { name: String! namespace: String! owner: Owner! + relations: [Relation!] system: System tags: [String!] title: String @@ -85,6 +87,7 @@ interface Component implements Dependency & Entity & Node & Ownable { namespace: String! owner: Owner! providesApis(after: String, before: String, first: Int, last: Int): APIConnection + relations: [Relation!] subComponentOf: Component system: System tags: [String!] @@ -122,6 +125,7 @@ type Database implements Dependency & Entity & Node & Ownable & Resource { name: String! namespace: String! owner: Owner! + relations: [Relation!] system: System tags: [String!] title: String @@ -143,6 +147,7 @@ type Department implements Entity & Group & Node { ownerOf(after: String, before: String, first: Int, last: Int): OwnableConnection parent: Group profile: GroupProfile + relations: [Relation!] tags: [String!] title: String type: String! @@ -174,6 +179,7 @@ type Domain implements Entity & Node & Ownable { name: String! namespace: String! owner: Owner! + relations: [Relation!] systems(after: String, before: String, first: Int, last: Int): SystemConnection tags: [String!] title: String @@ -194,6 +200,7 @@ interface Entity implements Node { links: [EntityLink!] name: String! namespace: String! + relations: [Relation!] tags: [String!] title: String } @@ -221,6 +228,7 @@ type EntityEdge implements Edge { fields: [{ fieldF: DESC }, { fieldG: ASC }] } } + { annotations: [{ field: "backstage.io/source-location", order: ASC }] } ] search: { term: "substring" @@ -253,17 +261,20 @@ input EntityFilter { } input EntityFilterExpression { + annotations: [EntityRawFilterField!] apiVersion: [JSON!] definition: [JSON!] description: [JSON!] kind: [JSON!] + labels: [EntityRawFilterField!] lifecycle: [JSON!] links: EntityFilterExpression_Links name: [JSON!] namespace: [JSON!] - parameters: [JSON!] + parameters: [EntityRawFilterField!] presence: [JSON!] profile: EntityFilterExpression_Profile + relations: EntityFilterExpression_Relations steps: EntityFilterExpression_Steps tags: [JSON!] target: [JSON!] @@ -285,11 +296,16 @@ input EntityFilterExpression_Profile { picture: [JSON!] } +input EntityFilterExpression_Relations { + targetRef: [JSON!] + type: [JSON!] +} + input EntityFilterExpression_Steps { action: [JSON!] id: [JSON!] if: [JSON!] - input: [JSON!] + input: [EntityRawFilterField!] name: [JSON!] } @@ -301,17 +317,20 @@ type EntityLink { } input EntityOrderField { + annotations: [EntityRawOrderField!] apiVersion: OrderDirection definition: OrderDirection description: OrderDirection kind: OrderDirection + labels: [EntityRawOrderField!] lifecycle: OrderDirection links: [EntityOrderField_Links!] name: OrderDirection namespace: OrderDirection - parameters: OrderDirection + parameters: [EntityRawOrderField!] presence: OrderDirection profile: [EntityOrderField_Profile!] + relations: [EntityOrderField_Relations!] steps: [EntityOrderField_Steps!] tags: OrderDirection target: OrderDirection @@ -333,11 +352,16 @@ input EntityOrderField_Profile { picture: OrderDirection } +input EntityOrderField_Relations { + targetRef: OrderDirection + type: OrderDirection +} + input EntityOrderField_Steps { action: OrderDirection id: OrderDirection if: OrderDirection - input: OrderDirection + input: [EntityRawOrderField!] name: OrderDirection } @@ -366,23 +390,32 @@ input EntityRawTextFilter { term: String! } +type EntityRef { + kind: String! + name: String! + namespace: String! +} + input EntityTextFilter { fields: EntityTextFilterFields term: String! } input EntityTextFilterFields { + annotations: [String!] apiVersion: Boolean definition: Boolean description: Boolean kind: Boolean + labels: [String!] lifecycle: Boolean links: EntityTextFilterFields_Links name: Boolean namespace: Boolean - parameters: Boolean + parameters: [String!] presence: Boolean profile: EntityTextFilterFields_Profile + relations: EntityTextFilterFields_Relations steps: EntityTextFilterFields_Steps tags: Boolean target: Boolean @@ -404,11 +437,16 @@ input EntityTextFilterFields_Profile { picture: Boolean } +input EntityTextFilterFields_Relations { + targetRef: Boolean + type: Boolean +} + input EntityTextFilterFields_Steps { action: Boolean id: Boolean if: Boolean - input: Boolean + input: [String!] name: Boolean } @@ -423,6 +461,7 @@ type FileLocation implements Entity & Location & Node { name: String! namespace: String! presence: String + relations: [Relation!] tags: [String!] target: String targets: [String!] @@ -445,6 +484,7 @@ type GRPC implements API & Entity & Node & Ownable { name: String! namespace: String! owner: Owner! + relations: [Relation!] system: System tags: [String!] title: String @@ -466,6 +506,7 @@ type GraphQL implements API & Entity & Node & Ownable { name: String! namespace: String! owner: Owner! + relations: [Relation!] system: System tags: [String!] title: String @@ -487,6 +528,7 @@ interface Group implements Entity & Node { ownerOf(after: String, before: String, first: Int, last: Int): OwnableConnection parent: Group profile: GroupProfile + relations: [Relation!] tags: [String!] title: String type: String! @@ -541,6 +583,7 @@ type Library implements Component & Dependency & Entity & Node & Ownable { namespace: String! owner: Owner! providesApis(after: String, before: String, first: Int, last: Int): APIConnection + relations: [Relation!] subComponentOf: Component system: System tags: [String!] @@ -559,6 +602,7 @@ interface Location implements Entity & Node { name: String! namespace: String! presence: String + relations: [Relation!] tags: [String!] target: String targets: [String!] @@ -585,6 +629,7 @@ type OpaqueAPI implements API & Entity & Node & Ownable { name: String! namespace: String! owner: Owner! + relations: [Relation!] system: System tags: [String!] title: String @@ -608,6 +653,7 @@ type OpaqueComponent implements Component & Dependency & Entity & Node & Ownable namespace: String! owner: Owner! providesApis(after: String, before: String, first: Int, last: Int): APIConnection + relations: [Relation!] subComponentOf: Component system: System tags: [String!] @@ -625,6 +671,7 @@ type OpaqueEntity implements Entity & Node { links: [EntityLink!] name: String! namespace: String! + relations: [Relation!] tags: [String!] title: String } @@ -644,6 +691,7 @@ type OpaqueGroup implements Entity & Group & Node { ownerOf(after: String, before: String, first: Int, last: Int): OwnableConnection parent: Group profile: GroupProfile + relations: [Relation!] tags: [String!] title: String type: String! @@ -660,6 +708,7 @@ type OpaqueLocation implements Entity & Location & Node { name: String! namespace: String! presence: String + relations: [Relation!] tags: [String!] target: String targets: [String!] @@ -680,6 +729,7 @@ type OpaqueResource implements Dependency & Entity & Node & Ownable & Resource { name: String! namespace: String! owner: Owner! + relations: [Relation!] system: System tags: [String!] title: String @@ -698,6 +748,7 @@ type OpaqueTemplate implements Entity & Node & Ownable & Template { namespace: String! owner: Owner parameters: JSONObject + relations: [Relation!] steps: [Step!]! tags: [String!] title: String @@ -719,6 +770,7 @@ type OpenAPI implements API & Entity & Node & Ownable { name: String! namespace: String! owner: Owner! + relations: [Relation!] system: System tags: [String!] title: String @@ -745,6 +797,7 @@ type Organization implements Entity & Group & Node { ownerOf(after: String, before: String, first: Int, last: Int): OwnableConnection parent: Group profile: GroupProfile + relations: [Relation!] tags: [String!] title: String type: String! @@ -781,6 +834,11 @@ type Query { nodes(ids: [ID!]!): [Node]! } +type Relation { + targetRef: EntityRef + type: String! +} + interface Resource implements Dependency & Entity & Node & Ownable { annotations: [KeyValuePair!] apiVersion: String! @@ -794,6 +852,7 @@ interface Resource implements Dependency & Entity & Node & Ownable { name: String! namespace: String! owner: Owner! + relations: [Relation!] system: System tags: [String!] title: String @@ -828,6 +887,7 @@ type Service implements Component & Dependency & Entity & Node & Ownable { namespace: String! owner: Owner! providesApis(after: String, before: String, first: Int, last: Int): APIConnection + relations: [Relation!] subComponentOf: Component system: System tags: [String!] @@ -847,6 +907,7 @@ type ServiceTemplate implements Entity & Node & Ownable & Template { namespace: String! owner: Owner parameters: JSONObject + relations: [Relation!] steps: [Step!]! tags: [String!] title: String @@ -876,6 +937,7 @@ type SubDepartment implements Entity & Group & Node { ownerOf(after: String, before: String, first: Int, last: Int): OwnableConnection parent: Group profile: GroupProfile + relations: [Relation!] tags: [String!] title: String type: String! @@ -895,6 +957,7 @@ type System implements Entity & Node & Ownable { name: String! namespace: String! owner: Owner! + relations: [Relation!] resources(after: String, before: String, first: Int, last: Int): ResourceConnection tags: [String!] title: String @@ -926,6 +989,7 @@ type Team implements Entity & Group & Node { ownerOf(after: String, before: String, first: Int, last: Int): OwnableConnection parent: Group profile: GroupProfile + relations: [Relation!] tags: [String!] title: String type: String! @@ -943,6 +1007,7 @@ interface Template implements Entity & Node & Ownable { namespace: String! owner: Owner parameters: JSONObject + relations: [Relation!] steps: [Step!]! tags: [String!] title: String @@ -960,6 +1025,7 @@ type URLLocation implements Entity & Location & Node { name: String! namespace: String! presence: String + relations: [Relation!] tags: [String!] target: String targets: [String!] @@ -980,6 +1046,7 @@ type User implements Entity & Node { namespace: String! ownerOf(after: String, before: String, first: Int, last: Int): OwnableConnection profile: UserProfile + relations: [Relation!] tags: [String!] title: String } @@ -1018,6 +1085,7 @@ type Website implements Component & Dependency & Entity & Node & Ownable { namespace: String! owner: Owner! providesApis(after: String, before: String, first: Int, last: Int): APIConnection + relations: [Relation!] subComponentOf: Component system: System tags: [String!] @@ -1070,6 +1138,7 @@ export type Api = { name: Scalars['String']['output']; namespace: Scalars['String']['output']; owner: Owner; + relations?: Maybe>; system?: Maybe; tags?: Maybe>; title?: Maybe; @@ -1121,6 +1190,7 @@ export type AsyncApi = Api & Entity & Node & Ownable & { name: Scalars['String']['output']; namespace: Scalars['String']['output']; owner: Owner; + relations?: Maybe>; system?: Maybe; tags?: Maybe>; title?: Maybe; @@ -1160,6 +1230,7 @@ export type Component = { namespace: Scalars['String']['output']; owner: Owner; providesApis?: Maybe; + relations?: Maybe>; subComponentOf?: Maybe; system?: Maybe; tags?: Maybe>; @@ -1240,6 +1311,7 @@ export type Database = Dependency & Entity & Node & Ownable & Resource & { name: Scalars['String']['output']; namespace: Scalars['String']['output']; owner: Owner; + relations?: Maybe>; system?: Maybe; tags?: Maybe>; title?: Maybe; @@ -1278,6 +1350,7 @@ export type Department = Entity & Group & Node & { ownerOf?: Maybe; parent?: Maybe; profile?: Maybe; + relations?: Maybe>; tags?: Maybe>; title?: Maybe; type: Scalars['String']['output']; @@ -1336,6 +1409,7 @@ export type Domain = Entity & Node & Ownable & { name: Scalars['String']['output']; namespace: Scalars['String']['output']; owner: Owner; + relations?: Maybe>; systems?: Maybe; tags?: Maybe>; title?: Maybe; @@ -1364,6 +1438,7 @@ export type Entity = { links?: Maybe>; name: Scalars['String']['output']; namespace: Scalars['String']['output']; + relations?: Maybe>; tags?: Maybe>; title?: Maybe; }; @@ -1393,6 +1468,7 @@ export type EntityEdge = Edge & { * fields: [{ fieldF: DESC }, { fieldG: ASC }] * } * } + * { annotations: [{ field: "backstage.io/source-location", order: ASC }] } * ] * search: { * term: "substring" @@ -1425,17 +1501,20 @@ export type EntityFilter = { }; export type EntityFilterExpression = { + annotations?: InputMaybe>; apiVersion?: InputMaybe>; definition?: InputMaybe>; description?: InputMaybe>; kind?: InputMaybe>; + labels?: InputMaybe>; lifecycle?: InputMaybe>; links?: InputMaybe; name?: InputMaybe>; namespace?: InputMaybe>; - parameters?: InputMaybe>; + parameters?: InputMaybe>; presence?: InputMaybe>; profile?: InputMaybe; + relations?: InputMaybe; steps?: InputMaybe; tags?: InputMaybe>; target?: InputMaybe>; @@ -1457,11 +1536,16 @@ export type EntityFilterExpression_Profile = { picture?: InputMaybe>; }; +export type EntityFilterExpression_Relations = { + targetRef?: InputMaybe>; + type?: InputMaybe>; +}; + export type EntityFilterExpression_Steps = { action?: InputMaybe>; id?: InputMaybe>; if?: InputMaybe>; - input?: InputMaybe>; + input?: InputMaybe>; name?: InputMaybe>; }; @@ -1474,17 +1558,20 @@ export type EntityLink = { }; export type EntityOrderField = { + annotations?: InputMaybe>; apiVersion?: InputMaybe; definition?: InputMaybe; description?: InputMaybe; kind?: InputMaybe; + labels?: InputMaybe>; lifecycle?: InputMaybe; links?: InputMaybe>; name?: InputMaybe; namespace?: InputMaybe; - parameters?: InputMaybe; + parameters?: InputMaybe>; presence?: InputMaybe; profile?: InputMaybe>; + relations?: InputMaybe>; steps?: InputMaybe>; tags?: InputMaybe; target?: InputMaybe; @@ -1506,11 +1593,16 @@ export type EntityOrderField_Profile = { picture?: InputMaybe; }; +export type EntityOrderField_Relations = { + targetRef?: InputMaybe; + type?: InputMaybe; +}; + export type EntityOrderField_Steps = { action?: InputMaybe; id?: InputMaybe; if?: InputMaybe; - input?: InputMaybe; + input?: InputMaybe>; name?: InputMaybe; }; @@ -1539,23 +1631,33 @@ export type EntityRawTextFilter = { term: Scalars['String']['input']; }; +export type EntityRef = { + __typename?: 'EntityRef'; + kind: Scalars['String']['output']; + name: Scalars['String']['output']; + namespace: Scalars['String']['output']; +}; + export type EntityTextFilter = { fields?: InputMaybe; term: Scalars['String']['input']; }; export type EntityTextFilterFields = { + annotations?: InputMaybe>; apiVersion?: InputMaybe; definition?: InputMaybe; description?: InputMaybe; kind?: InputMaybe; + labels?: InputMaybe>; lifecycle?: InputMaybe; links?: InputMaybe; name?: InputMaybe; namespace?: InputMaybe; - parameters?: InputMaybe; + parameters?: InputMaybe>; presence?: InputMaybe; profile?: InputMaybe; + relations?: InputMaybe; steps?: InputMaybe; tags?: InputMaybe; target?: InputMaybe; @@ -1577,11 +1679,16 @@ export type EntityTextFilterFields_Profile = { picture?: InputMaybe; }; +export type EntityTextFilterFields_Relations = { + targetRef?: InputMaybe; + type?: InputMaybe; +}; + export type EntityTextFilterFields_Steps = { action?: InputMaybe; id?: InputMaybe; if?: InputMaybe; - input?: InputMaybe; + input?: InputMaybe>; name?: InputMaybe; }; @@ -1597,6 +1704,7 @@ export type FileLocation = Entity & Location & Node & { name: Scalars['String']['output']; namespace: Scalars['String']['output']; presence?: Maybe; + relations?: Maybe>; tags?: Maybe>; target?: Maybe; targets?: Maybe>; @@ -1620,6 +1728,7 @@ export type Grpc = Api & Entity & Node & Ownable & { name: Scalars['String']['output']; namespace: Scalars['String']['output']; owner: Owner; + relations?: Maybe>; system?: Maybe; tags?: Maybe>; title?: Maybe; @@ -1658,6 +1767,7 @@ export type GraphQl = Api & Entity & Node & Ownable & { name: Scalars['String']['output']; namespace: Scalars['String']['output']; owner: Owner; + relations?: Maybe>; system?: Maybe; tags?: Maybe>; title?: Maybe; @@ -1695,6 +1805,7 @@ export type Group = { ownerOf?: Maybe; parent?: Maybe; profile?: Maybe; + relations?: Maybe>; tags?: Maybe>; title?: Maybe; type: Scalars['String']['output']; @@ -1768,6 +1879,7 @@ export type Library = Component & Dependency & Entity & Node & Ownable & { namespace: Scalars['String']['output']; owner: Owner; providesApis?: Maybe; + relations?: Maybe>; subComponentOf?: Maybe; system?: Maybe; tags?: Maybe>; @@ -1826,6 +1938,7 @@ export type Location = { name: Scalars['String']['output']; namespace: Scalars['String']['output']; presence?: Maybe; + relations?: Maybe>; tags?: Maybe>; target?: Maybe; targets?: Maybe>; @@ -1853,6 +1966,7 @@ export type OpaqueApi = Api & Entity & Node & Ownable & { name: Scalars['String']['output']; namespace: Scalars['String']['output']; owner: Owner; + relations?: Maybe>; system?: Maybe; tags?: Maybe>; title?: Maybe; @@ -1893,6 +2007,7 @@ export type OpaqueComponent = Component & Dependency & Entity & Node & Ownable & namespace: Scalars['String']['output']; owner: Owner; providesApis?: Maybe; + relations?: Maybe>; subComponentOf?: Maybe; system?: Maybe; tags?: Maybe>; @@ -1951,6 +2066,7 @@ export type OpaqueEntity = Entity & Node & { links?: Maybe>; name: Scalars['String']['output']; namespace: Scalars['String']['output']; + relations?: Maybe>; tags?: Maybe>; title?: Maybe; }; @@ -1971,6 +2087,7 @@ export type OpaqueGroup = Entity & Group & Node & { ownerOf?: Maybe; parent?: Maybe; profile?: Maybe; + relations?: Maybe>; tags?: Maybe>; title?: Maybe; type: Scalars['String']['output']; @@ -2012,6 +2129,7 @@ export type OpaqueLocation = Entity & Location & Node & { name: Scalars['String']['output']; namespace: Scalars['String']['output']; presence?: Maybe; + relations?: Maybe>; tags?: Maybe>; target?: Maybe; targets?: Maybe>; @@ -2033,6 +2151,7 @@ export type OpaqueResource = Dependency & Entity & Node & Ownable & Resource & { name: Scalars['String']['output']; namespace: Scalars['String']['output']; owner: Owner; + relations?: Maybe>; system?: Maybe; tags?: Maybe>; title?: Maybe; @@ -2068,6 +2187,7 @@ export type OpaqueTemplate = Entity & Node & Ownable & Template & { namespace: Scalars['String']['output']; owner?: Maybe; parameters?: Maybe; + relations?: Maybe>; steps: Array; tags?: Maybe>; title?: Maybe; @@ -2090,6 +2210,7 @@ export type OpenApi = Api & Entity & Node & Ownable & { name: Scalars['String']['output']; namespace: Scalars['String']['output']; owner: Owner; + relations?: Maybe>; system?: Maybe; tags?: Maybe>; title?: Maybe; @@ -2132,6 +2253,7 @@ export type Organization = Entity & Group & Node & { ownerOf?: Maybe; parent?: Maybe; profile?: Maybe; + relations?: Maybe>; tags?: Maybe>; title?: Maybe; type: Scalars['String']['output']; @@ -2223,6 +2345,12 @@ export type QueryNodesArgs = { ids: Array; }; +export type Relation = { + __typename?: 'Relation'; + targetRef?: Maybe; + type: Scalars['String']['output']; +}; + export type Resource = { annotations?: Maybe>; apiVersion: Scalars['String']['output']; @@ -2236,6 +2364,7 @@ export type Resource = { name: Scalars['String']['output']; namespace: Scalars['String']['output']; owner: Owner; + relations?: Maybe>; system?: Maybe; tags?: Maybe>; title?: Maybe; @@ -2289,6 +2418,7 @@ export type Service = Component & Dependency & Entity & Node & Ownable & { namespace: Scalars['String']['output']; owner: Owner; providesApis?: Maybe; + relations?: Maybe>; subComponentOf?: Maybe; system?: Maybe; tags?: Maybe>; @@ -2349,6 +2479,7 @@ export type ServiceTemplate = Entity & Node & Ownable & Template & { namespace: Scalars['String']['output']; owner?: Maybe; parameters?: Maybe; + relations?: Maybe>; steps: Array; tags?: Maybe>; title?: Maybe; @@ -2380,6 +2511,7 @@ export type SubDepartment = Entity & Group & Node & { ownerOf?: Maybe; parent?: Maybe; profile?: Maybe; + relations?: Maybe>; tags?: Maybe>; title?: Maybe; type: Scalars['String']['output']; @@ -2424,6 +2556,7 @@ export type System = Entity & Node & Ownable & { name: Scalars['String']['output']; namespace: Scalars['String']['output']; owner: Owner; + relations?: Maybe>; resources?: Maybe; tags?: Maybe>; title?: Maybe; @@ -2482,6 +2615,7 @@ export type Team = Entity & Group & Node & { ownerOf?: Maybe; parent?: Maybe; profile?: Maybe; + relations?: Maybe>; tags?: Maybe>; title?: Maybe; type: Scalars['String']['output']; @@ -2523,6 +2657,7 @@ export type Template = { namespace: Scalars['String']['output']; owner?: Maybe; parameters?: Maybe; + relations?: Maybe>; steps: Array; tags?: Maybe>; title?: Maybe; @@ -2541,6 +2676,7 @@ export type UrlLocation = Entity & Location & Node & { name: Scalars['String']['output']; namespace: Scalars['String']['output']; presence?: Maybe; + relations?: Maybe>; tags?: Maybe>; target?: Maybe; targets?: Maybe>; @@ -2562,6 +2698,7 @@ export type User = Entity & Node & { namespace: Scalars['String']['output']; ownerOf?: Maybe; profile?: Maybe; + relations?: Maybe>; tags?: Maybe>; title?: Maybe; }; @@ -2620,6 +2757,7 @@ export type Website = Component & Dependency & Entity & Node & Ownable & { namespace: Scalars['String']['output']; owner: Owner; providesApis?: Maybe; + relations?: Maybe>; subComponentOf?: Maybe; system?: Maybe; tags?: Maybe>; @@ -2780,21 +2918,25 @@ export type ResolversTypes = { EntityFilterExpression: EntityFilterExpression; EntityFilterExpression_Links: EntityFilterExpression_Links; EntityFilterExpression_Profile: EntityFilterExpression_Profile; + EntityFilterExpression_Relations: EntityFilterExpression_Relations; EntityFilterExpression_Steps: EntityFilterExpression_Steps; EntityLink: ResolverTypeWrapper; EntityOrderField: EntityOrderField; EntityOrderField_Links: EntityOrderField_Links; EntityOrderField_Profile: EntityOrderField_Profile; + EntityOrderField_Relations: EntityOrderField_Relations; EntityOrderField_Steps: EntityOrderField_Steps; EntityRawFilter: EntityRawFilter; EntityRawFilterExpression: EntityRawFilterExpression; EntityRawFilterField: EntityRawFilterField; EntityRawOrderField: EntityRawOrderField; EntityRawTextFilter: EntityRawTextFilter; + EntityRef: ResolverTypeWrapper; EntityTextFilter: EntityTextFilter; EntityTextFilterFields: EntityTextFilterFields; EntityTextFilterFields_Links: EntityTextFilterFields_Links; EntityTextFilterFields_Profile: EntityTextFilterFields_Profile; + EntityTextFilterFields_Relations: EntityTextFilterFields_Relations; EntityTextFilterFields_Steps: EntityTextFilterFields_Steps; FileLocation: ResolverTypeWrapper; GRPC: ResolverTypeWrapper & { owner: ResolversTypes['Owner'] }>; @@ -2827,6 +2969,7 @@ export type ResolversTypes = { Owner: ResolverTypeWrapper['Owner']>; PageInfo: ResolverTypeWrapper; Query: ResolverTypeWrapper<{}>; + Relation: ResolverTypeWrapper; Resource: ResolverTypeWrapper['Resource']>; ResourceConnection: ResolverTypeWrapper; ResourceEdge: ResolverTypeWrapper; @@ -2874,21 +3017,25 @@ export type ResolversParentTypes = { EntityFilterExpression: EntityFilterExpression; EntityFilterExpression_Links: EntityFilterExpression_Links; EntityFilterExpression_Profile: EntityFilterExpression_Profile; + EntityFilterExpression_Relations: EntityFilterExpression_Relations; EntityFilterExpression_Steps: EntityFilterExpression_Steps; EntityLink: EntityLink; EntityOrderField: EntityOrderField; EntityOrderField_Links: EntityOrderField_Links; EntityOrderField_Profile: EntityOrderField_Profile; + EntityOrderField_Relations: EntityOrderField_Relations; EntityOrderField_Steps: EntityOrderField_Steps; EntityRawFilter: EntityRawFilter; EntityRawFilterExpression: EntityRawFilterExpression; EntityRawFilterField: EntityRawFilterField; EntityRawOrderField: EntityRawOrderField; EntityRawTextFilter: EntityRawTextFilter; + EntityRef: EntityRef; EntityTextFilter: EntityTextFilter; EntityTextFilterFields: EntityTextFilterFields; EntityTextFilterFields_Links: EntityTextFilterFields_Links; EntityTextFilterFields_Profile: EntityTextFilterFields_Profile; + EntityTextFilterFields_Relations: EntityTextFilterFields_Relations; EntityTextFilterFields_Steps: EntityTextFilterFields_Steps; FileLocation: FileLocation; GRPC: Omit & { owner: ResolversParentTypes['Owner'] }; @@ -2920,6 +3067,7 @@ export type ResolversParentTypes = { Owner: ResolversUnionTypes['Owner']; PageInfo: PageInfo; Query: {}; + Relation: Relation; Resource: ResolversInterfaceTypes['Resource']; ResourceConnection: ResourceConnection; ResourceEdge: ResourceEdge; @@ -2956,10 +3104,6 @@ export type DiscriminationAliasDirectiveArgs = { export type DiscriminationAliasDirectiveResolver = DirectiveResolverFn; -export type ExcludeFromFilterDirectiveArgs = { }; - -export type ExcludeFromFilterDirectiveResolver = DirectiveResolverFn; - export type FieldDirectiveArgs = { at?: Maybe; default?: Maybe; @@ -2989,6 +3133,12 @@ export type ResolveDirectiveArgs = { export type ResolveDirectiveResolver = DirectiveResolverFn; +export type SourceTypeDirectiveArgs = { + name: Scalars['String']['input']; +}; + +export type SourceTypeDirectiveResolver = DirectiveResolverFn; + export type ApiResolvers = { __resolveType: TypeResolveFn<'AsyncAPI' | 'GRPC' | 'GraphQL' | 'OpaqueAPI' | 'OpenAPI', ParentType, ContextType>; annotations?: Resolver>, ParentType, ContextType>; @@ -3005,6 +3155,7 @@ export type ApiResolvers; namespace?: Resolver; owner?: Resolver; + relations?: Resolver>, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -3039,6 +3190,7 @@ export type AsyncApiResolvers; namespace?: Resolver; owner?: Resolver; + relations?: Resolver>, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -3064,6 +3216,7 @@ export type ComponentResolvers; owner?: Resolver; providesApis?: Resolver, ParentType, ContextType, Partial>; + relations?: Resolver>, ParentType, ContextType>; subComponentOf?: Resolver, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; @@ -3104,6 +3257,7 @@ export type DatabaseResolvers; namespace?: Resolver; owner?: Resolver; + relations?: Resolver>, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -3126,6 +3280,7 @@ export type DepartmentResolvers, ParentType, ContextType, Partial>; parent?: Resolver, ParentType, ContextType>; profile?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; type?: Resolver; @@ -3161,6 +3316,7 @@ export type DomainResolvers; namespace?: Resolver; owner?: Resolver; + relations?: Resolver>, ParentType, ContextType>; systems?: Resolver, ParentType, ContextType, Partial>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -3184,6 +3340,7 @@ export type EntityResolvers>, ParentType, ContextType>; name?: Resolver; namespace?: Resolver; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; }; @@ -3209,6 +3366,13 @@ export type EntityLinkResolvers; }; +export type EntityRefResolvers = { + kind?: Resolver; + name?: Resolver; + namespace?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type FileLocationResolvers = { annotations?: Resolver>, ParentType, ContextType>; apiVersion?: Resolver; @@ -3220,6 +3384,7 @@ export type FileLocationResolvers; namespace?: Resolver; presence?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; target?: Resolver, ParentType, ContextType>; targets?: Resolver>, ParentType, ContextType>; @@ -3243,6 +3408,7 @@ export type GrpcResolvers; namespace?: Resolver; owner?: Resolver; + relations?: Resolver>, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -3265,6 +3431,7 @@ export type GraphQlResolvers; namespace?: Resolver; owner?: Resolver; + relations?: Resolver>, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -3288,6 +3455,7 @@ export type GroupResolvers, ParentType, ContextType, Partial>; parent?: Resolver, ParentType, ContextType>; profile?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; type?: Resolver; @@ -3344,6 +3512,7 @@ export type LibraryResolvers; owner?: Resolver; providesApis?: Resolver, ParentType, ContextType, Partial>; + relations?: Resolver>, ParentType, ContextType>; subComponentOf?: Resolver, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; @@ -3364,6 +3533,7 @@ export type LocationResolvers; namespace?: Resolver; presence?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; target?: Resolver, ParentType, ContextType>; targets?: Resolver>, ParentType, ContextType>; @@ -3391,6 +3561,7 @@ export type OpaqueApiResolvers; namespace?: Resolver; owner?: Resolver; + relations?: Resolver>, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -3415,6 +3586,7 @@ export type OpaqueComponentResolvers; owner?: Resolver; providesApis?: Resolver, ParentType, ContextType, Partial>; + relations?: Resolver>, ParentType, ContextType>; subComponentOf?: Resolver, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; @@ -3433,6 +3605,7 @@ export type OpaqueEntityResolvers>, ParentType, ContextType>; name?: Resolver; namespace?: Resolver; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -3453,6 +3626,7 @@ export type OpaqueGroupResolvers, ParentType, ContextType, Partial>; parent?: Resolver, ParentType, ContextType>; profile?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; type?: Resolver; @@ -3470,6 +3644,7 @@ export type OpaqueLocationResolvers; namespace?: Resolver; presence?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; target?: Resolver, ParentType, ContextType>; targets?: Resolver>, ParentType, ContextType>; @@ -3491,6 +3666,7 @@ export type OpaqueResourceResolvers; namespace?: Resolver; owner?: Resolver; + relations?: Resolver>, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -3510,6 +3686,7 @@ export type OpaqueTemplateResolvers; owner?: Resolver, ParentType, ContextType>; parameters?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; steps?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -3532,6 +3709,7 @@ export type OpenApiResolvers; namespace?: Resolver; owner?: Resolver; + relations?: Resolver>, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -3554,6 +3732,7 @@ export type OrganizationResolvers, ParentType, ContextType, Partial>; parent?: Resolver, ParentType, ContextType>; profile?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; type?: Resolver; @@ -3597,6 +3776,12 @@ export type QueryResolvers>, ParentType, ContextType, RequireFields>; }; +export type RelationResolvers = { + targetRef?: Resolver, ParentType, ContextType>; + type?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type ResourceResolvers = { __resolveType: TypeResolveFn<'Database' | 'OpaqueResource', ParentType, ContextType>; annotations?: Resolver>, ParentType, ContextType>; @@ -3611,6 +3796,7 @@ export type ResourceResolvers; namespace?: Resolver; owner?: Resolver; + relations?: Resolver>, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -3647,6 +3833,7 @@ export type ServiceResolvers; owner?: Resolver; providesApis?: Resolver, ParentType, ContextType, Partial>; + relations?: Resolver>, ParentType, ContextType>; subComponentOf?: Resolver, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; @@ -3667,6 +3854,7 @@ export type ServiceTemplateResolvers; owner?: Resolver, ParentType, ContextType>; parameters?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; steps?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -3698,6 +3886,7 @@ export type SubDepartmentResolvers, ParentType, ContextType, Partial>; parent?: Resolver, ParentType, ContextType>; profile?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; type?: Resolver; @@ -3718,6 +3907,7 @@ export type SystemResolvers; namespace?: Resolver; owner?: Resolver; + relations?: Resolver>, ParentType, ContextType>; resources?: Resolver, ParentType, ContextType, Partial>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -3752,6 +3942,7 @@ export type TeamResolvers, ParentType, ContextType, Partial>; parent?: Resolver, ParentType, ContextType>; profile?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; type?: Resolver; @@ -3771,6 +3962,7 @@ export type TemplateResolvers; owner?: Resolver, ParentType, ContextType>; parameters?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; steps?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; @@ -3788,6 +3980,7 @@ export type UrlLocationResolvers; namespace?: Resolver; presence?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; target?: Resolver, ParentType, ContextType>; targets?: Resolver>, ParentType, ContextType>; @@ -3809,6 +4002,7 @@ export type UserResolvers; ownerOf?: Resolver, ParentType, ContextType, Partial>; profile?: Resolver, ParentType, ContextType>; + relations?: Resolver>, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -3851,6 +4045,7 @@ export type WebsiteResolvers; owner?: Resolver; providesApis?: Resolver, ParentType, ContextType, Partial>; + relations?: Resolver>, ParentType, ContextType>; subComponentOf?: Resolver, ParentType, ContextType>; system?: Resolver, ParentType, ContextType>; tags?: Resolver>, ParentType, ContextType>; @@ -3883,6 +4078,7 @@ export type Resolvers = { EntityConnection?: EntityConnectionResolvers; EntityEdge?: EntityEdgeResolvers; EntityLink?: EntityLinkResolvers; + EntityRef?: EntityRefResolvers; FileLocation?: FileLocationResolvers; GRPC?: GrpcResolvers; GraphQL?: GraphQlResolvers; @@ -3911,6 +4107,7 @@ export type Resolvers = { Owner?: OwnerResolvers; PageInfo?: PageInfoResolvers; Query?: QueryResolvers; + Relation?: RelationResolvers; Resource?: ResourceResolvers; ResourceConnection?: ResourceConnectionResolvers; ResourceEdge?: ResourceEdgeResolvers; @@ -3935,11 +4132,11 @@ export type Resolvers = { export type DirectiveResolvers = { discriminates?: DiscriminatesDirectiveResolver; discriminationAlias?: DiscriminationAliasDirectiveResolver; - excludeFromFilter?: ExcludeFromFilterDirectiveResolver; field?: FieldDirectiveResolver; implements?: ImplementsDirectiveResolver; relation?: RelationDirectiveResolver; resolve?: ResolveDirectiveResolver; + sourceType?: SourceTypeDirectiveResolver; }; export type Json = Scalars["JSON"]; diff --git a/plugins/graphql-backend-module-catalog/src/catalog/catalog.graphql b/plugins/graphql-backend-module-catalog/src/catalog/catalog.graphql index 1a8481dac7..f59e9fdc3d 100644 --- a/plugins/graphql-backend-module-catalog/src/catalog/catalog.graphql +++ b/plugins/graphql-backend-module-catalog/src/catalog/catalog.graphql @@ -15,10 +15,11 @@ extend interface Entity apiVersion: String! @field(at: "apiVersion") title: String @field(at: "metadata.title") description: String @field(at: "metadata.description") - labels: [KeyValuePair!] @field(at: "metadata.labels") @excludeFromFilter - annotations: [KeyValuePair!] @field(at: "metadata.annotations") @excludeFromFilter + labels: [KeyValuePair!] @field(at: "metadata.labels") @sourceType(name: "JSONObject") + annotations: [KeyValuePair!] @field(at: "metadata.annotations") @sourceType(name: "JSONObject") tags: [String!] @field(at: "metadata.tags") links: [EntityLink!] @field(at: "metadata.links") + relations: [Relation!] @field(at: "relations") } type EntityLink { @@ -28,6 +29,19 @@ type EntityLink { type: String } +type Relation { + type: String! + # FIXME: @resolve/@field directives work only on top level of node object + # target: Entity @resolve(at: "targetRef") + targetRef: EntityRef @sourceType(name: "String") +} + +type EntityRef { + kind: String! + namespace: String! + name: String! +} + interface Location @implements(interface: "Entity") @discriminates(with: "spec.type", opaqueType: "OpaqueLocation") diff --git a/plugins/graphql-backend-module-catalog/src/catalog/catalog.ts b/plugins/graphql-backend-module-catalog/src/catalog/catalog.ts index 41648fee76..bffca45b38 100644 --- a/plugins/graphql-backend-module-catalog/src/catalog/catalog.ts +++ b/plugins/graphql-backend-module-catalog/src/catalog/catalog.ts @@ -3,6 +3,7 @@ import { loadFilesSync } from '@graphql-tools/load-files'; import { resolvePackagePath } from '@backstage/backend-common'; import { Relation } from '../relation'; import { GraphQLModule } from '@frontside/hydraphql'; +import { EntityRelation, parseEntityRef } from '@backstage/catalog-model'; const catalogSchemaPath = resolvePackagePath( '@frontside/backstage-plugin-graphql-backend-module-catalog', @@ -15,7 +16,10 @@ export const Catalog = (): GraphQLModule => ({ postTransform: Relation().postTransform, module: createModule({ id: 'catalog-entities', - typeDefs: [...Relation().module.typeDefs, ...loadFilesSync(catalogSchemaPath)], + typeDefs: [ + ...Relation().module.typeDefs, + ...loadFilesSync(catalogSchemaPath), + ], resolvers: { ...Relation().module.config.resolvers, Entity: { @@ -31,6 +35,9 @@ export const Catalog = (): GraphQLModule => ({ })) : null, }, + Relation: { + targetRef: (relation: EntityRelation) => parseEntityRef(relation.targetRef), + }, }, - }) + }), }); diff --git a/plugins/graphql-backend-module-catalog/src/common/common.graphql b/plugins/graphql-backend-module-catalog/src/common/common.graphql index 064c5e5878..3b7cf7ebe6 100644 --- a/plugins/graphql-backend-module-catalog/src/common/common.graphql +++ b/plugins/graphql-backend-module-catalog/src/common/common.graphql @@ -3,7 +3,9 @@ directive @relation( nodeType: String kind: String ) on FIELD_DEFINITION -directive @excludeFromFilter on FIELD_DEFINITION +directive @sourceType( + name: String! +) on FIELD_DEFINITION scalar JSON scalar JSONObject @@ -65,6 +67,7 @@ input EntityFilterExpression { fields: [{ fieldF: DESC }, { fieldG: ASC }] } } + { annotations: [{ field: "backstage.io/source-location", order: ASC }] } ] search: { term: "substring" diff --git a/plugins/graphql-backend-module-catalog/src/generateInputTypes.test.ts b/plugins/graphql-backend-module-catalog/src/generateInputTypes.test.ts index a3e58b918e..9e7024abeb 100644 --- a/plugins/graphql-backend-module-catalog/src/generateInputTypes.test.ts +++ b/plugins/graphql-backend-module-catalog/src/generateInputTypes.test.ts @@ -1,6 +1,4 @@ -import { - transformSchema, -} from '@frontside/hydraphql'; +import { transformSchema } from '@frontside/hydraphql'; import { DocumentNode, GraphQLNamedType, printType } from 'graphql'; import { Module, createModule, gql } from 'graphql-modules'; import { Relation } from './relation/relation'; @@ -23,22 +21,22 @@ describe('generateEntitiesQueryInputTypes', () => { } `); - expect(printType(schema.getType('EntityOrderField') as GraphQLNamedType).split('\n')).toEqual([ - 'input EntityOrderField {', - ' _dummy: OrderDirection', - '}', - ]); - expect(printType(schema.getType('EntityTextFilterFields') as GraphQLNamedType).split('\n')).toEqual([ - 'input EntityTextFilterFields {', - ' _dummy: Boolean', - '}', - ]); - expect(printType(schema.getType('EntityFilterExpression') as GraphQLNamedType).split('\n')).toEqual([ - 'input EntityFilterExpression {', - ' _dummy: [JSON!]', - '}', - ]); - }) + expect( + printType(schema.getType('EntityOrderField') as GraphQLNamedType).split( + '\n', + ), + ).toEqual(['input EntityOrderField {', ' _dummy: OrderDirection', '}']); + expect( + printType( + schema.getType('EntityTextFilterFields') as GraphQLNamedType, + ).split('\n'), + ).toEqual(['input EntityTextFilterFields {', ' _dummy: Boolean', '}']); + expect( + printType( + schema.getType('EntityFilterExpression') as GraphQLNamedType, + ).split('\n'), + ).toEqual(['input EntityFilterExpression {', ' _dummy: [JSON!]', '}']); + }); it('should generate plain input types for primitive fields', () => { const schema = transform(gql` @@ -52,7 +50,11 @@ describe('generateEntitiesQueryInputTypes', () => { } `); - expect(printType(schema.getType('EntityOrderField') as GraphQLNamedType).split('\n')).toEqual([ + expect( + printType(schema.getType('EntityOrderField') as GraphQLNamedType).split( + '\n', + ), + ).toEqual([ 'input EntityOrderField {', ' name: OrderDirection', ' kind: OrderDirection', @@ -62,7 +64,11 @@ describe('generateEntitiesQueryInputTypes', () => { ' description: OrderDirection', '}', ]); - expect(printType(schema.getType('EntityTextFilterFields') as GraphQLNamedType).split('\n')).toEqual([ + expect( + printType( + schema.getType('EntityTextFilterFields') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ 'input EntityTextFilterFields {', ' name: Boolean', ' kind: Boolean', @@ -72,7 +78,11 @@ describe('generateEntitiesQueryInputTypes', () => { ' description: Boolean', '}', ]); - expect(printType(schema.getType('EntityFilterExpression') as GraphQLNamedType).split('\n')).toEqual([ + expect( + printType( + schema.getType('EntityFilterExpression') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ 'input EntityFilterExpression {', ' name: [JSON!]', ' kind: [JSON!]', @@ -82,7 +92,7 @@ describe('generateEntitiesQueryInputTypes', () => { ' description: [JSON!]', '}', ]); - }) + }); it('should generate input types for composite fields', () => { const schema = transform(gql` @@ -96,42 +106,66 @@ describe('generateEntitiesQueryInputTypes', () => { } `); - expect(printType(schema.getType('EntityOrderField') as GraphQLNamedType).split('\n')).toEqual([ + expect( + printType(schema.getType('EntityOrderField') as GraphQLNamedType).split( + '\n', + ), + ).toEqual([ 'input EntityOrderField {', ' metadata: [EntityOrderField_Metadata!]', '}', ]); - expect(printType(schema.getType('EntityOrderField_Metadata') as GraphQLNamedType).split('\n')).toEqual([ + expect( + printType( + schema.getType('EntityOrderField_Metadata') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ 'input EntityOrderField_Metadata {', ' name: OrderDirection', ' namespace: OrderDirection', '}', ]); - expect(printType(schema.getType('EntityTextFilterFields') as GraphQLNamedType).split('\n')).toEqual([ + expect( + printType( + schema.getType('EntityTextFilterFields') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ 'input EntityTextFilterFields {', ' metadata: EntityTextFilterFields_Metadata', '}', ]); - expect(printType(schema.getType('EntityTextFilterFields_Metadata') as GraphQLNamedType).split('\n')).toEqual([ + expect( + printType( + schema.getType('EntityTextFilterFields_Metadata') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ 'input EntityTextFilterFields_Metadata {', ' name: Boolean', ' namespace: Boolean', '}', ]); - expect(printType(schema.getType('EntityFilterExpression') as GraphQLNamedType).split('\n')).toEqual([ + expect( + printType( + schema.getType('EntityFilterExpression') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ 'input EntityFilterExpression {', ' metadata: EntityFilterExpression_Metadata', '}', ]); - expect(printType(schema.getType('EntityFilterExpression_Metadata') as GraphQLNamedType).split('\n')).toEqual([ + expect( + printType( + schema.getType('EntityFilterExpression_Metadata') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ 'input EntityFilterExpression_Metadata {', ' name: [JSON!]', ' namespace: [JSON!]', '}', ]); - }) + }); it('should generate input types for mixed fields (plain and composite)', () => { const schema = transform(gql` @@ -153,61 +187,97 @@ describe('generateEntitiesQueryInputTypes', () => { } `); - expect(printType(schema.getType('EntityOrderField') as GraphQLNamedType).split('\n')).toEqual([ + expect( + printType(schema.getType('EntityOrderField') as GraphQLNamedType).split( + '\n', + ), + ).toEqual([ 'input EntityOrderField {', ' kind: OrderDirection', ' target: EntityOrderField_Target', '}', ]); - expect(printType(schema.getType('EntityOrderField_Target') as GraphQLNamedType).split('\n')).toEqual([ + expect( + printType( + schema.getType('EntityOrderField_Target') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ 'input EntityOrderField_Target {', ' order: OrderDirection', ' fields: [EntityOrderField__Target!]', '}', ]); - expect(printType(schema.getType('EntityOrderField__Target') as GraphQLNamedType).split('\n')).toEqual([ + expect( + printType( + schema.getType('EntityOrderField__Target') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ 'input EntityOrderField__Target {', ' host: OrderDirection', ' port: OrderDirection', '}', ]); - expect(printType(schema.getType('EntityTextFilterFields') as GraphQLNamedType).split('\n')).toEqual([ + expect( + printType( + schema.getType('EntityTextFilterFields') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ 'input EntityTextFilterFields {', ' kind: Boolean', ' target: EntityTextFilterFields_Target', '}', ]); - expect(printType(schema.getType('EntityTextFilterFields_Target') as GraphQLNamedType).split('\n')).toEqual([ + expect( + printType( + schema.getType('EntityTextFilterFields_Target') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ 'input EntityTextFilterFields_Target {', ' include: Boolean', ' fields: EntityTextFilterFields__Target', '}', ]); - expect(printType(schema.getType('EntityTextFilterFields__Target') as GraphQLNamedType).split('\n')).toEqual([ + expect( + printType( + schema.getType('EntityTextFilterFields__Target') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ 'input EntityTextFilterFields__Target {', ' host: Boolean', ' port: Boolean', '}', ]); - expect(printType(schema.getType('EntityFilterExpression') as GraphQLNamedType).split('\n')).toEqual([ + expect( + printType( + schema.getType('EntityFilterExpression') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ 'input EntityFilterExpression {', ' kind: [JSON!]', ' target: EntityFilterExpression_Target', '}', ]); - expect(printType(schema.getType('EntityFilterExpression_Target') as GraphQLNamedType).split('\n')).toEqual([ + expect( + printType( + schema.getType('EntityFilterExpression_Target') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ 'input EntityFilterExpression_Target {', ' values: [JSON!]', ' fields: EntityFilterExpression__Target', '}', ]); - expect(printType(schema.getType('EntityFilterExpression__Target') as GraphQLNamedType).split('\n')).toEqual([ + expect( + printType( + schema.getType('EntityFilterExpression__Target') as GraphQLNamedType, + ).split('\n'), + ).toEqual([ 'input EntityFilterExpression__Target {', ' host: [JSON!]', ' port: [JSON!]', '}', ]); - }) -}) + }); +}); diff --git a/plugins/graphql-backend-module-catalog/src/generateInputTypes.ts b/plugins/graphql-backend-module-catalog/src/generateInputTypes.ts index 29c09bff18..9a97a47a08 100644 --- a/plugins/graphql-backend-module-catalog/src/generateInputTypes.ts +++ b/plugins/graphql-backend-module-catalog/src/generateInputTypes.ts @@ -12,31 +12,84 @@ import { GraphQLList, GraphQLNonNull, GraphQLInputType, - GraphQLType, isLeafType, - isWrappingType, isCompositeType, + GraphQLInputFieldConfig, + GraphQLString, + GraphQLNamedType, } from 'graphql'; import { addTypes, getDirective } from '@graphql-tools/utils'; import GraphQLJSON from 'graphql-type-json'; -export function isWrappingLeafType(type: GraphQLType): boolean { - if (isLeafType(type)) return true - if (isWrappingType(type)) return isWrappingLeafType(type.ofType) - return false +interface TypeParams { + isLeaf?: boolean; + isComposite?: boolean; + isJsonObject?: boolean; } -export function isWrappingCompositeType(type: GraphQLType): boolean { - if (isCompositeType(type)) return true - if (isWrappingType(type)) return isWrappingCompositeType(type.ofType) - return false +function isJsonObject(type: GraphQLNamedType) { + return type.name === 'JSONObject'; } -function mergeLeafAndCompositeOrderFieldTypes( +function createCompositeOrderFieldsType( fieldName: string, orderFieldTypeName: string, compositeOrderFieldType: GraphQLInputType, - orderType: GraphQLEnumType, +) { + return { + type: new GraphQLList( + new GraphQLNonNull( + new GraphQLInputObjectType({ + ...( + getNamedType(compositeOrderFieldType) as GraphQLInputObjectType + ).toConfig(), + name: `${orderFieldTypeName}__${fieldName[0].toUpperCase()}${fieldName.slice( + 1, + )}`, + }), + ), + ), + }; +} + +function createCompositeTextFilterFieldsType( + fieldName: string, + textFilterFieldsTypeName: string, + compositeTextFilterFieldsType: GraphQLInputType, +) { + return { + type: new GraphQLInputObjectType({ + ...( + getNamedType(compositeTextFilterFieldsType) as GraphQLInputObjectType + ).toConfig(), + name: `${textFilterFieldsTypeName}__${fieldName[0].toUpperCase()}${fieldName.slice( + 1, + )}`, + }), + }; +} + +function createCompositeFilterExpressionType( + fieldName: string, + filterExpressionTypeName: string, + compositeFilterExpressionType: GraphQLInputType, +) { + return { + type: new GraphQLInputObjectType({ + ...( + getNamedType(compositeFilterExpressionType) as GraphQLInputObjectType + ).toConfig(), + name: `${filterExpressionTypeName}__${fieldName[0].toUpperCase()}${fieldName.slice( + 1, + )}`, + }), + }; +} + +function mergeLeafAndCompositeOrderFieldTypes( + fieldName: string, + orderFieldTypeName: string, + fields: Record, ) { // NOTE: Should we check if the type is already in the schema? return { @@ -44,25 +97,7 @@ function mergeLeafAndCompositeOrderFieldTypes( name: `${orderFieldTypeName}_${fieldName[0].toUpperCase()}${fieldName.slice( 1, )}`, - fields: { - order: { type: orderType }, - fields: { - type: new GraphQLList( - new GraphQLNonNull( - new GraphQLInputObjectType({ - ...( - getNamedType( - compositeOrderFieldType, - ) as GraphQLInputObjectType - ).toConfig(), - name: `${orderFieldTypeName}__${fieldName[0].toUpperCase()}${fieldName.slice( - 1, - )}`, - }), - ), - ), - }, - }, + fields, }), }; } @@ -70,28 +105,14 @@ function mergeLeafAndCompositeOrderFieldTypes( function mergeLeafAndCompositeTextFilterFieldsTypes( fieldName: string, textFilterFieldsTypeName: string, - compositeTextFilterFieldsType: GraphQLInputType, + fields: Record, ) { return { type: new GraphQLInputObjectType({ name: `${textFilterFieldsTypeName}_${fieldName[0].toUpperCase()}${fieldName.slice( 1, )}`, - fields: { - include: { type: GraphQLBoolean }, - fields: { - type: new GraphQLInputObjectType({ - ...( - getNamedType( - compositeTextFilterFieldsType, - ) as GraphQLInputObjectType - ).toConfig(), - name: `${textFilterFieldsTypeName}__${fieldName[0].toUpperCase()}${fieldName.slice( - 1, - )}`, - }), - }, - }, + fields, }), }; } @@ -99,35 +120,21 @@ function mergeLeafAndCompositeTextFilterFieldsTypes( function mergeLeafAndCompositeFilterExpressionTypes( fieldName: string, filterExpressionTypeName: string, - compositeFilterExpressionType: GraphQLInputType, + fields: Record, ) { return { type: new GraphQLInputObjectType({ name: `${filterExpressionTypeName}_${fieldName[0].toUpperCase()}${fieldName.slice( 1, )}`, - fields: { - values: { type: new GraphQLList(new GraphQLNonNull(GraphQLJSON)) }, - fields: { - type: new GraphQLInputObjectType({ - ...( - getNamedType( - compositeFilterExpressionType, - ) as GraphQLInputObjectType - ).toConfig(), - name: `${filterExpressionTypeName}__${fieldName[0].toUpperCase()}${fieldName.slice( - 1, - )}`, - }), - }, - }, + fields, }), }; } function getTypeConfigs( fieldName: string, - fieldTypes: Map, + fieldTypes: Map, orderFieldTypeConfig: ReturnType, textFilterFieldsTypeConfig: ReturnType, filterExpressionTypeConfig: ReturnType, @@ -222,18 +229,19 @@ function processTypes( orderFieldTypeConfig, textFilterFieldsTypeConfig, filterExpressionTypeConfig, + entityRawOrderFieldType, + entityRawFilterFieldType, }: { isNested?: boolean; orderDirectionType: GraphQLEnumType; orderFieldTypeConfig: ReturnType; textFilterFieldsTypeConfig: ReturnType; filterExpressionTypeConfig: ReturnType; + entityRawOrderFieldType: GraphQLInputObjectType; + entityRawFilterFieldType: GraphQLInputObjectType; }, ) { - const fieldTypes = new Map< - string, - { isLeaf?: boolean; isComposite?: boolean } - >(); + const fieldTypes = new Map(); types.forEach(type => { if (isUnionType(type)) { @@ -243,27 +251,168 @@ function processTypes( orderFieldTypeConfig, textFilterFieldsTypeConfig, filterExpressionTypeConfig, + entityRawOrderFieldType, + entityRawFilterFieldType, }); return; } if (isInterfaceType(type)) { - processTypes(schema, [...schema.getImplementations(type).interfaces, ...schema.getImplementations(type).objects], { - isNested, - orderDirectionType, - orderFieldTypeConfig, - textFilterFieldsTypeConfig, - filterExpressionTypeConfig, - }); + processTypes( + schema, + [ + ...schema.getImplementations(type).interfaces, + ...schema.getImplementations(type).objects, + ], + { + isNested, + orderDirectionType, + orderFieldTypeConfig, + textFilterFieldsTypeConfig, + filterExpressionTypeConfig, + entityRawOrderFieldType, + entityRawFilterFieldType, + }, + ); } Object.entries(type.getFields()).forEach(([fieldName, field]) => { const [fieldDirective] = getDirective(schema, field, 'field') ?? []; - const [excludeFromFilter] = getDirective(schema, field, 'excludeFromFilter') ?? [] - if (!fieldDirective && !isNested || excludeFromFilter) return; + const [resolveDirective] = getDirective(schema, field, 'resolve') ?? [] + const [sourceTypeDirective] = + getDirective(schema, field, 'sourceType') ?? []; + if (!fieldDirective && !isNested || resolveDirective) return; + + const fieldType = sourceTypeDirective + ? schema.getType(sourceTypeDirective.name) + : field.type; + + if (!fieldType) + throw new Error( + `Can't find "${sourceTypeDirective.name}" type described in @sourceType(name: "${sourceTypeDirective.name}") directive for "${field.name}" field of "${type.name}" type/interface`, + ); + const fieldNamedType = getNamedType(fieldType); + const order = { type: orderDirectionType }; + const include = { type: GraphQLBoolean }; + // NOTE: Should we handle enums differently? + const values = { type: new GraphQLList(new GraphQLNonNull(GraphQLJSON)) }; + const orderRawFields = { + type: new GraphQLList(new GraphQLNonNull(entityRawOrderFieldType)), + }; + const includeRawFields = { + type: new GraphQLList(new GraphQLNonNull(GraphQLString)), + }; + const valuesRawFields = { + type: new GraphQLList(new GraphQLNonNull(entityRawFilterFieldType)), + }; - // const sourceFieldName = fieldDirective.at ?? fieldName + if ( + isJsonObject(fieldNamedType) && + !fieldTypes.get(fieldName)?.isJsonObject + ) { + if (fieldTypes.get(fieldName)?.isComposite) { + const { + compositeOrderFieldTypeConfig, + compositeTextFilterFieldsTypeConfig, + compositeFilterExpressionTypeConfig, + } = getTypeConfigs( + fieldName, + fieldTypes, + orderFieldTypeConfig, + textFilterFieldsTypeConfig, + filterExpressionTypeConfig, + ); + + orderFieldTypeConfig.fields[fieldName] = + mergeLeafAndCompositeOrderFieldTypes( + fieldName, + orderFieldTypeConfig.name, + { + ...(fieldTypes.get(fieldName)?.isLeaf ? { order } : {}), + rawFields: orderRawFields, + fields: createCompositeOrderFieldsType( + fieldName, + orderFieldTypeConfig.name, + new GraphQLInputObjectType(compositeOrderFieldTypeConfig), + ), + }, + ); + + textFilterFieldsTypeConfig.fields[fieldName] = + mergeLeafAndCompositeTextFilterFieldsTypes( + fieldName, + textFilterFieldsTypeConfig.name, + { + ...(fieldTypes.get(fieldName)?.isLeaf ? { include } : {}), + rawFields: includeRawFields, + fields: createCompositeTextFilterFieldsType( + fieldName, + textFilterFieldsTypeConfig.name, + new GraphQLInputObjectType( + compositeTextFilterFieldsTypeConfig, + ), + ), + }, + ); + + filterExpressionTypeConfig.fields[fieldName] = + mergeLeafAndCompositeFilterExpressionTypes( + fieldName, + filterExpressionTypeConfig.name, + { + ...(fieldTypes.get(fieldName)?.isLeaf ? { values } : {}), + rawFields: valuesRawFields, + fields: createCompositeFilterExpressionType( + fieldName, + filterExpressionTypeConfig.name, + new GraphQLInputObjectType( + compositeFilterExpressionTypeConfig, + ), + ), + }, + ); + } else { + if (fieldTypes.get(fieldName)?.isLeaf) { + orderFieldTypeConfig.fields[fieldName] = + mergeLeafAndCompositeOrderFieldTypes( + fieldName, + orderFieldTypeConfig.name, + { + order, + rawFields: orderRawFields, + }, + ); + textFilterFieldsTypeConfig.fields[fieldName] = + mergeLeafAndCompositeTextFilterFieldsTypes( + fieldName, + textFilterFieldsTypeConfig.name, + { + include, + rawFields: includeRawFields, + }, + ); + filterExpressionTypeConfig.fields[fieldName] = + mergeLeafAndCompositeFilterExpressionTypes( + fieldName, + filterExpressionTypeConfig.name, + { + values, + rawFields: valuesRawFields, + }, + ); + } else { + orderFieldTypeConfig.fields[fieldName] = orderRawFields; + textFilterFieldsTypeConfig.fields[fieldName] = includeRawFields; + filterExpressionTypeConfig.fields[fieldName] = valuesRawFields; + } + } + fieldTypes.set(fieldName, { + ...fieldTypes.get(fieldName), + isJsonObject: true, + }); + } if ( - isWrappingLeafType(field.type) && + !isJsonObject(fieldNamedType) && + isLeafType(fieldNamedType) && !fieldTypes.get(fieldName)?.isLeaf ) { if (fieldTypes.get(fieldName)?.isComposite) { @@ -272,40 +421,95 @@ function processTypes( mergeLeafAndCompositeOrderFieldTypes( fieldName, orderFieldTypeConfig.name, - orderFieldTypeConfig.fields[fieldName].type, - orderDirectionType, + { + order, + ...(fieldTypes.get(fieldName)?.isJsonObject + ? { rawFields: orderRawFields } + : {}), + fields: createCompositeOrderFieldsType( + fieldName, + orderFieldTypeConfig.name, + orderFieldTypeConfig.fields[fieldName].type, + ), + }, ); textFilterFieldsTypeConfig.fields[fieldName] = mergeLeafAndCompositeTextFilterFieldsTypes( fieldName, textFilterFieldsTypeConfig.name, - textFilterFieldsTypeConfig.fields[fieldName].type, + { + include, + ...(fieldTypes.get(fieldName)?.isJsonObject + ? { rawFields: includeRawFields } + : {}), + fields: createCompositeTextFilterFieldsType( + fieldName, + textFilterFieldsTypeConfig.name, + textFilterFieldsTypeConfig.fields[fieldName].type, + ), + }, ); filterExpressionTypeConfig.fields[fieldName] = mergeLeafAndCompositeFilterExpressionTypes( fieldName, filterExpressionTypeConfig.name, - filterExpressionTypeConfig.fields[fieldName].type, + { + values, + ...(fieldTypes.get(fieldName)?.isJsonObject + ? { rawFields: valuesRawFields } + : {}), + fields: createCompositeFilterExpressionType( + fieldName, + filterExpressionTypeConfig.name, + filterExpressionTypeConfig.fields[fieldName].type, + ), + }, ); } else { - orderFieldTypeConfig.fields[fieldName] = { type: orderDirectionType }; - textFilterFieldsTypeConfig.fields[fieldName] = { - type: GraphQLBoolean, - }; - // NOTE: Should we handle enums differently? - filterExpressionTypeConfig.fields[fieldName] = { - type: new GraphQLList(new GraphQLNonNull(GraphQLJSON)), - }; + if (fieldTypes.get(fieldName)?.isJsonObject) { + orderFieldTypeConfig.fields[fieldName] = + mergeLeafAndCompositeOrderFieldTypes( + fieldName, + orderFieldTypeConfig.name, + { + order, + rawFields: orderRawFields, + }, + ); + textFilterFieldsTypeConfig.fields[fieldName] = + mergeLeafAndCompositeTextFilterFieldsTypes( + fieldName, + textFilterFieldsTypeConfig.name, + { + include, + rawFields: includeRawFields, + }, + ); + filterExpressionTypeConfig.fields[fieldName] = + mergeLeafAndCompositeFilterExpressionTypes( + fieldName, + filterExpressionTypeConfig.name, + { + values, + rawFields: valuesRawFields, + }, + ); + } else { + orderFieldTypeConfig.fields[fieldName] = order; + textFilterFieldsTypeConfig.fields[fieldName] = include; + filterExpressionTypeConfig.fields[fieldName] = values; + } } - fieldTypes.set(fieldName, { isLeaf: true }); + fieldTypes.set(fieldName, { + ...fieldTypes.get(fieldName), + isLeaf: true, + }); } - if (isWrappingCompositeType(field.type)) { - const fieldType = getNamedType(field.type) as GraphQLCompositeType; - + if (isCompositeType(fieldNamedType)) { const { compositeOrderFieldTypeConfig, compositeTextFilterFieldsTypeConfig, @@ -318,12 +522,14 @@ function processTypes( filterExpressionTypeConfig, ); - processTypes(schema, [fieldType], { + processTypes(schema, [fieldNamedType], { isNested: true, orderDirectionType, orderFieldTypeConfig: compositeOrderFieldTypeConfig, textFilterFieldsTypeConfig: compositeTextFilterFieldsTypeConfig, filterExpressionTypeConfig: compositeFilterExpressionTypeConfig, + entityRawOrderFieldType, + entityRawFilterFieldType, }); if (fieldTypes.get(fieldName)?.isLeaf) { @@ -331,48 +537,131 @@ function processTypes( mergeLeafAndCompositeOrderFieldTypes( fieldName, orderFieldTypeConfig.name, - new GraphQLInputObjectType(compositeOrderFieldTypeConfig), - orderDirectionType, + { + order, + ...(fieldTypes.get(fieldName)?.isJsonObject + ? { rawFields: orderRawFields } + : {}), + fields: createCompositeOrderFieldsType( + fieldName, + orderFieldTypeConfig.name, + new GraphQLInputObjectType(compositeOrderFieldTypeConfig), + ), + }, ); textFilterFieldsTypeConfig.fields[fieldName] = mergeLeafAndCompositeTextFilterFieldsTypes( fieldName, textFilterFieldsTypeConfig.name, - new GraphQLInputObjectType(compositeTextFilterFieldsTypeConfig), + { + include, + ...(fieldTypes.get(fieldName)?.isJsonObject + ? { rawFields: includeRawFields } + : {}), + fields: createCompositeTextFilterFieldsType( + fieldName, + textFilterFieldsTypeConfig.name, + new GraphQLInputObjectType( + compositeTextFilterFieldsTypeConfig, + ), + ), + }, ); filterExpressionTypeConfig.fields[fieldName] = mergeLeafAndCompositeFilterExpressionTypes( fieldName, filterExpressionTypeConfig.name, - new GraphQLInputObjectType(compositeFilterExpressionTypeConfig), + { + values, + ...(fieldTypes.get(fieldName)?.isJsonObject + ? { rawFields: valuesRawFields } + : {}), + fields: createCompositeFilterExpressionType( + fieldName, + filterExpressionTypeConfig.name, + new GraphQLInputObjectType( + compositeFilterExpressionTypeConfig, + ), + ), + }, ); } else { - orderFieldTypeConfig.fields[fieldName] = { - type: new GraphQLList( - new GraphQLNonNull( - new GraphQLInputObjectType(compositeOrderFieldTypeConfig), + if (fieldTypes.get(fieldName)?.isJsonObject) { + orderFieldTypeConfig.fields[fieldName] = + mergeLeafAndCompositeOrderFieldTypes( + fieldName, + orderFieldTypeConfig.name, + { + rawFields: orderRawFields, + fields: createCompositeOrderFieldsType( + fieldName, + orderFieldTypeConfig.name, + new GraphQLInputObjectType(compositeOrderFieldTypeConfig), + ), + }, + ); + textFilterFieldsTypeConfig.fields[fieldName] = + mergeLeafAndCompositeTextFilterFieldsTypes( + fieldName, + textFilterFieldsTypeConfig.name, + { + rawFields: includeRawFields, + fields: createCompositeTextFilterFieldsType( + fieldName, + textFilterFieldsTypeConfig.name, + new GraphQLInputObjectType( + compositeTextFilterFieldsTypeConfig, + ), + ), + }, + ); + filterExpressionTypeConfig.fields[fieldName] = + mergeLeafAndCompositeFilterExpressionTypes( + fieldName, + filterExpressionTypeConfig.name, + { + rawFields: valuesRawFields, + fields: createCompositeFilterExpressionType( + fieldName, + filterExpressionTypeConfig.name, + new GraphQLInputObjectType( + compositeFilterExpressionTypeConfig, + ), + ), + }, + ); + } else { + orderFieldTypeConfig.fields[fieldName] = { + type: new GraphQLList( + new GraphQLNonNull( + new GraphQLInputObjectType(compositeOrderFieldTypeConfig), + ), + ), + }; + textFilterFieldsTypeConfig.fields[fieldName] = { + type: new GraphQLInputObjectType( + compositeTextFilterFieldsTypeConfig, + ), + }; + filterExpressionTypeConfig.fields[fieldName] = { + type: new GraphQLInputObjectType( + compositeFilterExpressionTypeConfig, ), - ), - }; - textFilterFieldsTypeConfig.fields[fieldName] = { - type: new GraphQLInputObjectType( - compositeTextFilterFieldsTypeConfig, - ), - }; - filterExpressionTypeConfig.fields[fieldName] = { - type: new GraphQLInputObjectType(compositeFilterExpressionTypeConfig), - }; + }; + } } - fieldTypes.set(fieldName, { isComposite: true }); + fieldTypes.set(fieldName, { + ...fieldTypes.get(fieldName), + isComposite: true, + }); } }); }); } -// TODO Handle `JSONObject` type export function generateEntitiesQueryInputTypes( schema: GraphQLSchema, ): GraphQLSchema { @@ -410,29 +699,48 @@ export function generateEntitiesQueryInputTypes( const filterExpressionTypeConfig = filterExpressionType.toConfig(); filterExpressionTypeConfig.fields = {}; + const entityRawOrderFieldType = schema.getType('EntityRawOrderField'); + if (!entityRawOrderFieldType || !isInputObjectType(entityRawOrderFieldType)) { + throw new Error( + '"EntityRawOrderField" type not found or isn\'t input type', + ); + } + + const entityRawFilterFieldType = schema.getType('EntityRawFilterField'); + if ( + !entityRawFilterFieldType || + !isInputObjectType(entityRawFilterFieldType) + ) { + throw new Error( + '"EntityRawFilterField" type not found or isn\'t input type', + ); + } + processTypes(schema, [entityType], { orderDirectionType, orderFieldTypeConfig, textFilterFieldsTypeConfig, filterExpressionTypeConfig, + entityRawOrderFieldType, + entityRawFilterFieldType, }); if (!Object.keys(orderFieldTypeConfig.fields).length) { orderFieldTypeConfig.fields = { _dummy: { type: orderDirectionType }, - } + }; } if (!Object.keys(textFilterFieldsTypeConfig.fields).length) { textFilterFieldsTypeConfig.fields = { _dummy: { type: GraphQLBoolean }, - } + }; } if (!Object.keys(filterExpressionTypeConfig.fields).length) { filterExpressionTypeConfig.fields = { _dummy: { type: new GraphQLList(new GraphQLNonNull(GraphQLJSON)) }, - } + }; } return addTypes(schema, [ diff --git a/plugins/graphql-backend-module-catalog/src/resolvers.ts b/plugins/graphql-backend-module-catalog/src/resolvers.ts index 28a8006ed3..a2475fcdea 100644 --- a/plugins/graphql-backend-module-catalog/src/resolvers.ts +++ b/plugins/graphql-backend-module-catalog/src/resolvers.ts @@ -73,9 +73,15 @@ function traverseFieldDirectives( } Object.entries(type.getFields()).forEach(([fieldName, field]) => { const [fieldDirective] = getDirective(schema, field, 'field') ?? []; - if (!fieldDirective && !isNested) return; + const [resolveDirective] = getDirective(schema, field, 'resolve') ?? []; + const [sourceTypeDirective] = + getDirective(schema, field, 'sourceType') ?? []; + if (!fieldDirective && !isNested || resolveDirective) return; - const unwrappedType = getNamedType(field.type); + const fieldType = sourceTypeDirective + ? schema.getType(sourceTypeDirective.name) + : field.type; + const unwrappedType = getNamedType(fieldType); const mappedFieldName = fieldDirective?.at ?? fieldName; const fields = (fieldMap.get(fieldName)?.fields ?? new Set()).add( @@ -114,7 +120,7 @@ function traverseFieldDirectives( } function mapMatchFilterToQueryFilter( - match: Record>, + match: Record>, fieldMap: Map }>, parentKey?: string, ): ( @@ -122,18 +128,28 @@ function mapMatchFilterToQueryFilter( | { anyOf: { key: string; values: unknown[] }[] } )[] { if (parentKey && fieldMap.get(parentKey)?.isLeaf) { - const { values, fields } = match; - const fieldKeys = [...(fieldMap.get(parentKey)?.fields ?? [])]; + const { values, rawFields, fields } = match; + const sourceFields = [...(fieldMap.get(parentKey)?.fields ?? [])]; return [ ...(Array.isArray(values) ? [ { anyOf: [ - ...fieldKeys.map(fieldKey => ({ key: fieldKey, values })), + ...sourceFields.map(fieldKey => ({ key: fieldKey, values })), ], }, ] : []), + ...(rawFields + ? (rawFields as { key: string; values: unknown[] }[]).map(rawField => ({ + anyOf: [ + ...sourceFields.map(fieldKey => ({ + key: `${fieldKey}.${rawField.key}`, + values: rawField.values, + })), + ], + })) + : []), ...(fields ? mapMatchFilterToQueryFilter( fields as Record, @@ -145,17 +161,27 @@ function mapMatchFilterToQueryFilter( } return Object.entries(match).reduce((filters, [fieldName, fieldValues]) => { const matchKey = parentKey ? `${parentKey}.${fieldName}` : fieldName; + const sourceFields = [...(fieldMap.get(matchKey)?.fields ?? [])] if (Array.isArray(fieldValues)) { - const fieldKeys = [...(fieldMap.get(matchKey)?.fields ?? [])]; + const [firstValue] = fieldValues + if (firstValue && typeof firstValue === 'object' && 'key' in firstValue && typeof firstValue?.key === 'string') { + return [ + ...filters, + ...(fieldValues as { key: string; values: unknown[] }[]).map((rawField) => ({ + anyOf: sourceFields.map(fieldKey => ({ + key: `${fieldKey}.${rawField.key}`, + values: rawField.values + })) + })) + ] + } return [ ...filters, { - anyOf: [ - ...fieldKeys.map(fieldKey => ({ + anyOf: sourceFields.map(fieldKey => ({ key: fieldKey, - values: fieldValues, + values: fieldValues as unknown[], })), - ], }, ]; } @@ -171,7 +197,12 @@ function mapOrderFieldsToQueryOrder( orders: Record< string, | OrderDirection - | { order?: OrderDirection; fields: Record[] } + | { field: string; order: OrderDirection }[] + | { + order?: OrderDirection; + rawFields?: { field: string; order: OrderDirection }[]; + fields?: Record[]; + } | Record[] >[], fieldMap: Map }>, @@ -183,23 +214,53 @@ function mapOrderFieldsToQueryOrder( } const [[fieldName, directionOrChild]] = Object.entries(fieldOrder); const orderKey = parentKey ? `${parentKey}.${fieldName}` : fieldName; + const sourceFields = [...(fieldMap.get(orderKey)?.fields ?? [])]; if (typeof directionOrChild === 'string') { - return [...(fieldMap.get(orderKey)?.fields ?? [])].map(fieldKey => ({ + return sourceFields.map(fieldKey => ({ field: fieldKey, order: directionOrChild.toLowerCase(), })); } if (Array.isArray(directionOrChild)) { + const [firstChild] = directionOrChild; + if ( + // NOTE: isRawFields + typeof firstChild.field === 'string' && + ['ASC', 'DESC'].includes(firstChild.order) + ) { + return [ + ...directionOrChild.flatMap(({ field, order }) => + sourceFields.map(fieldKey => ({ + field: `${fieldKey}.${field}`, + order: order.toLowerCase(), + })), + ), + ]; + } return [ - ...mapOrderFieldsToQueryOrder(directionOrChild, fieldMap, orderKey), + ...mapOrderFieldsToQueryOrder( + directionOrChild as Record[], + fieldMap, + orderKey, + ), ]; } - const { fields, order } = directionOrChild; - if (fields && order) { + const { fields, rawFields, order } = directionOrChild; + if (Object.keys(directionOrChild).length > 1) { throw new Error( - 'Cannot have both "fields" and "order" in order field object', + 'Only one of "fields", "rawFields" and "order" is allowed in order field object', ); } + if (rawFields) { + return [ + ...rawFields.flatMap(rawField => + sourceFields.map(fieldKey => ({ + field: `${fieldKey}.${rawField.field}`, + order: rawField.order.toLowerCase(), + })), + ), + ]; + } if (fields) { return mapOrderFieldsToQueryOrder(fields, fieldMap, orderKey); } @@ -214,14 +275,31 @@ function mapOrderFieldsToQueryOrder( } function mapSearchFilterToTextSearch( - search: Record>, + search: Record< + string, + | boolean + | string[] + | Record< + string, + { + include?: boolean; + rawFields?: string[]; + fields?: Record; + } + > + | Record + >, fieldMap: Map }>, parentKey?: string, ): string[] { if (parentKey && fieldMap.get(parentKey)?.isLeaf) { - const { include, fields } = search; + const { include, rawFields, fields } = search; + const sourceFields = [...(fieldMap.get(parentKey)?.fields ?? [])]; return [ - ...(include ? fieldMap.get(parentKey)?.fields ?? [] : []), + ...(include ? sourceFields : []), + ...((rawFields ?? []) as string[]).flatMap(rawField => + sourceFields.map(fieldKey => `${fieldKey}.${rawField}`), + ), ...(fields ? mapSearchFilterToTextSearch( fields as Record, @@ -233,17 +311,28 @@ function mapSearchFilterToTextSearch( } return Object.entries(search).flatMap(([fieldName, value]) => { const searchKey = parentKey ? `${parentKey}.${fieldName}` : fieldName; + const sourceFields = [...(fieldMap.get(fieldName)?.fields ?? [])]; if (value === true) { - return [...(fieldMap.get(fieldName)?.fields ?? [])]; + return sourceFields; + } + if (Array.isArray(value)) { + return value.flatMap(rawField => + sourceFields.map(fieldKey => `${fieldKey}.${rawField}`), + ); } if (typeof value === 'object') { - return [...mapSearchFilterToTextSearch(value, fieldMap, searchKey)]; + return [ + ...mapSearchFilterToTextSearch( + value as Record, + fieldMap, + searchKey, + ), + ]; } return []; }); } -// TODO Handle labels and annotations separately export const queryResolvers: () => Resolvers = () => { let fieldMap: Map }> | null = null; @@ -276,18 +365,18 @@ export const queryResolvers: () => Resolvers = () => { first?: number; after?: string; last?: number; - before: string; - filter: { + before?: string; + filter?: { match?: Record[]; order?: Record[]; search?: { term: string; fields: Record }; }; - rawFilter: { + rawFilter?: { filter?: { fields: unknown[] }[]; orderFields?: { field: string; order: OrderDirection }[]; fullTextFilter?: { term: string; fields?: string[] }; }; - }, + } = {}, { catalog }: { catalog: CatalogApi }, { schema }: { schema: GraphQLSchema }, ): Promise> => { @@ -404,7 +493,7 @@ export const queryResolvers: () => Resolvers = () => { limit, }); - // TODO Reuse field's resolvers + // TODO Reuse field's resolvers https://github.com/thefrontside/HydraphQL/pull/22 return { edges: items.map(item => ({ cursor: Buffer.from(