diff --git a/packages/graphql/src/classes/Subgraph.ts b/packages/graphql/src/classes/Subgraph.ts index ffb88ed7ef..b8097cfedd 100644 --- a/packages/graphql/src/classes/Subgraph.ts +++ b/packages/graphql/src/classes/Subgraph.ts @@ -114,7 +114,7 @@ export class Subgraph { if (def.kind === Kind.OBJECT_TYPE_DEFINITION) { const entity = schemaModel.getEntity(def.name.value); - if (schemaModel.isConcreteEntity(entity)) { + if (entity?.isConcreteEntity()) { const keyAnnotation = entity.annotations.key; // If there is a @key directive with `resolvable` set to false, then do not add __resolveReference diff --git a/packages/graphql/src/schema-model/Neo4jGraphQLSchemaModel.ts b/packages/graphql/src/schema-model/Neo4jGraphQLSchemaModel.ts index 0ead6e0e93..981ffb8170 100644 --- a/packages/graphql/src/schema-model/Neo4jGraphQLSchemaModel.ts +++ b/packages/graphql/src/schema-model/Neo4jGraphQLSchemaModel.ts @@ -21,8 +21,8 @@ import { Neo4jGraphQLSchemaValidationError } from "../classes"; import type { Operation } from "./Operation"; import type { Annotations, Annotation } from "./annotation/Annotation"; import { annotationToKey } from "./annotation/Annotation"; -import { CompositeEntity } from "./entity/CompositeEntity"; -import { ConcreteEntity } from "./entity/ConcreteEntity"; +import type { CompositeEntity } from "./entity/CompositeEntity"; +import type { ConcreteEntity } from "./entity/ConcreteEntity"; import type { Entity } from "./entity/Entity"; import { ConcreteEntityAdapter } from "./entity/model-adapters/ConcreteEntityAdapter"; @@ -81,14 +81,6 @@ export class Neo4jGraphQLSchemaModel { return this.concreteEntities.filter((entity) => entity.name === name && entity.matchLabels(labels)); } - public isConcreteEntity(entity?: Entity): entity is ConcreteEntity { - return entity instanceof ConcreteEntity; - } - - public isCompositeEntity(entity?: Entity): entity is CompositeEntity { - return entity instanceof CompositeEntity; - } - private addAnnotation(annotation: Annotation): void { const annotationKey = annotationToKey(annotation); const existingAnnotation = this.annotations[annotationKey]; diff --git a/packages/graphql/src/schema-model/attribute/AttributeType.ts b/packages/graphql/src/schema-model/attribute/AttributeType.ts index c2852935d7..cd40f24ab5 100644 --- a/packages/graphql/src/schema-model/attribute/AttributeType.ts +++ b/packages/graphql/src/schema-model/attribute/AttributeType.ts @@ -69,7 +69,7 @@ export class Neo4jCartesianPointType { export class Neo4jPointType { public readonly name: string; public readonly isRequired: boolean; - constructor( isRequired: boolean) { + constructor(isRequired: boolean) { this.name = Neo4jGraphQLSpatialType.Point; this.isRequired = isRequired; } @@ -88,13 +88,14 @@ export class ObjectType { public readonly name: string; public readonly isRequired: boolean; // TODO: add fields - + constructor(name: string, isRequired: boolean) { this.name = name; this.isRequired = isRequired; } } +// TODO: consider replacing this with a isList field on the other classes export class ListType { public readonly name: string; public readonly ofType: Exclude; diff --git a/packages/graphql/src/schema-model/entity/CompositeEntity.ts b/packages/graphql/src/schema-model/entity/CompositeEntity.ts index b600b7abe0..b82091a1c7 100644 --- a/packages/graphql/src/schema-model/entity/CompositeEntity.ts +++ b/packages/graphql/src/schema-model/entity/CompositeEntity.ts @@ -20,15 +20,8 @@ import type { ConcreteEntity } from "./ConcreteEntity"; import type { Entity } from "./Entity"; -/** Entity for abstract GraphQL types, Interface and Union */ -export class CompositeEntity implements Entity { - public readonly name: string; - public concreteEntities: ConcreteEntity[]; - // TODO: add type interface or union, and for interface add fields - // TODO: add annotations - - constructor({ name, concreteEntities }: { name: string; concreteEntities: ConcreteEntity[] }) { - this.name = name; - this.concreteEntities = concreteEntities; - } +/** models the concept of an Abstract Type */ +export interface CompositeEntity extends Entity { + readonly name: string; + concreteEntities: ConcreteEntity[]; } diff --git a/packages/graphql/src/schema-model/entity/ConcreteEntity.ts b/packages/graphql/src/schema-model/entity/ConcreteEntity.ts index c4e25c61bb..db32e6f77e 100644 --- a/packages/graphql/src/schema-model/entity/ConcreteEntity.ts +++ b/packages/graphql/src/schema-model/entity/ConcreteEntity.ts @@ -23,6 +23,7 @@ import type { Annotation, Annotations } from "../annotation/Annotation"; import { annotationToKey } from "../annotation/Annotation"; import type { Attribute } from "../attribute/Attribute"; import type { Relationship } from "../relationship/Relationship"; +import type { CompositeEntity } from "./CompositeEntity"; import type { Entity } from "./Entity"; export class ConcreteEntity implements Entity { @@ -59,6 +60,12 @@ export class ConcreteEntity implements Entity { this.addRelationship(relationship); } } + isConcreteEntity(): this is ConcreteEntity { + return true; + } + isCompositeEntity(): this is CompositeEntity { + return false; + } public matchLabels(labels: string[]) { return setsAreEqual(new Set(labels), this.labels); diff --git a/packages/graphql/src/schema-model/entity/Entity.ts b/packages/graphql/src/schema-model/entity/Entity.ts index ba59a90a21..c856bc5148 100644 --- a/packages/graphql/src/schema-model/entity/Entity.ts +++ b/packages/graphql/src/schema-model/entity/Entity.ts @@ -17,10 +17,15 @@ * limitations under the License. */ +import type { CompositeEntity } from "./CompositeEntity"; +import type { ConcreteEntity } from "./ConcreteEntity"; export interface Entity { readonly name: string; + isConcreteEntity(): this is ConcreteEntity; + isCompositeEntity(): this is CompositeEntity; + // attributes // relationships // annotations diff --git a/packages/graphql/src/schema-model/entity/InterfaceEntity.ts b/packages/graphql/src/schema-model/entity/InterfaceEntity.ts new file mode 100644 index 0000000000..f748d4f3d1 --- /dev/null +++ b/packages/graphql/src/schema-model/entity/InterfaceEntity.ts @@ -0,0 +1,106 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Neo4jGraphQLSchemaValidationError } from "../../classes"; +import type { Annotation, Annotations } from "../annotation/Annotation"; +import { annotationToKey } from "../annotation/Annotation"; +import type { Attribute } from "../attribute/Attribute"; +import type { Relationship } from "../relationship/Relationship"; +import type { CompositeEntity } from "./CompositeEntity"; +import type { ConcreteEntity } from "./ConcreteEntity"; + +export class InterfaceEntity implements CompositeEntity { + public readonly name: string; + public readonly concreteEntities: ConcreteEntity[]; + public readonly attributes: Map = new Map(); + public readonly relationships: Map = new Map(); + public readonly annotations: Partial = {}; + + constructor({ + name, + concreteEntities, + attributes = [], + annotations = [], + relationships = [], + }: { + name: string; + concreteEntities: ConcreteEntity[]; + attributes?: Attribute[]; + annotations?: Annotation[]; + relationships?: Relationship[]; + }) { + this.name = name; + this.concreteEntities = concreteEntities; + for (const attribute of attributes) { + this.addAttribute(attribute); + } + + for (const annotation of annotations) { + this.addAnnotation(annotation); + } + + for (const relationship of relationships) { + this.addRelationship(relationship); + } + } + + isConcreteEntity(): this is ConcreteEntity { + return false; + } + isCompositeEntity(): this is CompositeEntity { + return true; + } + + private addAttribute(attribute: Attribute): void { + if (this.attributes.has(attribute.name)) { + throw new Neo4jGraphQLSchemaValidationError(`Attribute ${attribute.name} already exists in ${this.name}`); + } + this.attributes.set(attribute.name, attribute); + } + + private addAnnotation(annotation: Annotation): void { + const annotationKey = annotationToKey(annotation); + const existingAnnotation = this.annotations[annotationKey]; + + if (existingAnnotation) { + throw new Neo4jGraphQLSchemaValidationError(`Annotation ${annotationKey} already exists in ${this.name}`); + } + + // We cast to any because we aren't narrowing the Annotation type here. + // There's no reason to narrow either, since we care more about performance. + this.annotations[annotationKey] = annotation as any; + } + + public addRelationship(relationship: Relationship): void { + if (this.relationships.has(relationship.name)) { + throw new Neo4jGraphQLSchemaValidationError( + `Attribute ${relationship.name} already exists in ${this.name}` + ); + } + this.relationships.set(relationship.name, relationship); + } + + public findAttribute(name: string): Attribute | undefined { + return this.attributes.get(name); + } + + public findRelationship(name: string): Relationship | undefined { + return this.relationships.get(name); + } +} diff --git a/packages/graphql/src/schema-model/entity/model-adapters/CompositeEntityAdapter.ts b/packages/graphql/src/schema-model/entity/UnionEntity.ts similarity index 67% rename from packages/graphql/src/schema-model/entity/model-adapters/CompositeEntityAdapter.ts rename to packages/graphql/src/schema-model/entity/UnionEntity.ts index a61d88b0bc..07fbbe5d02 100644 --- a/packages/graphql/src/schema-model/entity/model-adapters/CompositeEntityAdapter.ts +++ b/packages/graphql/src/schema-model/entity/UnionEntity.ts @@ -17,17 +17,21 @@ * limitations under the License. */ -import type { ConcreteEntityAdapter } from "./ConcreteEntityAdapter"; +import type { ConcreteEntity } from "./ConcreteEntity"; +import type { CompositeEntity } from "./CompositeEntity"; -// As the composite entity is not yet implemented, this is a placeholder -export class CompositeEntityAdapter { +export class UnionEntity implements CompositeEntity { public readonly name: string; - public concreteEntities: ConcreteEntityAdapter[]; - // TODO: add type interface or union, and for interface add fields - // TODO: add annotations + public concreteEntities: ConcreteEntity[]; - constructor({ name, concreteEntities }: { name: string; concreteEntities: ConcreteEntityAdapter[] }) { + constructor({ name, concreteEntities }: { name: string; concreteEntities: ConcreteEntity[] }) { this.name = name; this.concreteEntities = concreteEntities; } + isConcreteEntity(): this is ConcreteEntity { + return false; + } + isCompositeEntity(): this is CompositeEntity { + return true; + } } diff --git a/packages/graphql/src/schema-model/entity/model-adapters/ConcreteEntityAdapter.ts b/packages/graphql/src/schema-model/entity/model-adapters/ConcreteEntityAdapter.ts index def5dbcdde..410a975686 100644 --- a/packages/graphql/src/schema-model/entity/model-adapters/ConcreteEntityAdapter.ts +++ b/packages/graphql/src/schema-model/entity/model-adapters/ConcreteEntityAdapter.ts @@ -20,13 +20,14 @@ import { AttributeAdapter } from "../../attribute/model-adapters/AttributeAdapter"; import type { Relationship } from "../../relationship/Relationship"; import { getFromMap } from "../../utils/get-from-map"; -import type { Entity } from "../Entity"; import { singular, plural } from "../../utils/string-manipulation"; import type { ConcreteEntity } from "../ConcreteEntity"; import type { Attribute } from "../../attribute/Attribute"; import { RelationshipAdapter } from "../../relationship/model-adapters/RelationshipAdapter"; import type { Annotations } from "../../annotation/Annotation"; import { ConcreteEntityOperations } from "./ConcreteEntityOperations"; +import type { InterfaceEntityAdapter } from "./InterfaceEntityAdapter"; +import type { UnionEntityAdapter } from "./UnionEntityAdapter"; export class ConcreteEntityAdapter { public readonly name: string; @@ -40,7 +41,7 @@ export class ConcreteEntityAdapter { private uniqueFieldsKeys: string[] = []; private constrainableFieldsKeys: string[] = []; - private _relatedEntities: Entity[] | undefined; + private _relatedEntities: (ConcreteEntityAdapter | InterfaceEntityAdapter | UnionEntityAdapter)[] | undefined; private _singular: string | undefined; private _plural: string | undefined; @@ -75,10 +76,7 @@ export class ConcreteEntityAdapter { private initRelationships(relationships: Map) { for (const [relationshipName, relationship] of relationships.entries()) { - this.relationships.set( - relationshipName, - new RelationshipAdapter(relationship, this) - ); + this.relationships.set(relationshipName, new RelationshipAdapter(relationship, this)); } } @@ -94,7 +92,7 @@ export class ConcreteEntityAdapter { return this.constrainableFieldsKeys.map((key) => getFromMap(this.attributes, key)); } - public get relatedEntities(): Entity[] { + public get relatedEntities(): (ConcreteEntityAdapter | InterfaceEntityAdapter | UnionEntityAdapter)[] { if (!this._relatedEntities) { this._relatedEntities = [...this.relationships.values()].map((relationship) => relationship.target); } @@ -107,7 +105,7 @@ export class ConcreteEntityAdapter { } public getMainLabel(): string { - return this.getLabels()[0] as string; + return this.getLabels()[0] as string; } public get singular(): string { diff --git a/packages/graphql/src/schema-model/entity/model-adapters/InterfaceEntityAdapter.ts b/packages/graphql/src/schema-model/entity/model-adapters/InterfaceEntityAdapter.ts new file mode 100644 index 0000000000..1fa622748f --- /dev/null +++ b/packages/graphql/src/schema-model/entity/model-adapters/InterfaceEntityAdapter.ts @@ -0,0 +1,64 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Annotations } from "../../annotation/Annotation"; +import type { Attribute } from "../../attribute/Attribute"; +import { AttributeAdapter } from "../../attribute/model-adapters/AttributeAdapter"; +import { RelationshipAdapter } from "../../relationship/model-adapters/RelationshipAdapter"; +import type { Relationship } from "../../relationship/Relationship"; +import type { ConcreteEntity } from "../ConcreteEntity"; +import type { InterfaceEntity } from "../InterfaceEntity"; +import { ConcreteEntityAdapter } from "./ConcreteEntityAdapter"; + +export class InterfaceEntityAdapter { + public readonly name: string; + public concreteEntities: ConcreteEntityAdapter[]; + public readonly attributes: Map = new Map(); + public readonly relationships: Map = new Map(); + public readonly annotations: Partial; + + constructor(entity: InterfaceEntity) { + this.name = entity.name; + this.concreteEntities = []; + this.annotations = entity.annotations; + this.initAttributes(entity.attributes); + this.initRelationships(entity.relationships); + this.initConcreteEntities(entity.concreteEntities); + } + + private initConcreteEntities(entities: ConcreteEntity[]) { + for (const entity of entities) { + const entityAdapter = new ConcreteEntityAdapter(entity); + this.concreteEntities.push(entityAdapter); + } + } + + private initAttributes(attributes: Map) { + for (const [attributeName, attribute] of attributes.entries()) { + const attributeAdapter = new AttributeAdapter(attribute); + this.attributes.set(attributeName, attributeAdapter); + } + } + + private initRelationships(relationships: Map) { + for (const [relationshipName, relationship] of relationships.entries()) { + this.relationships.set(relationshipName, new RelationshipAdapter(relationship, this)); + } + } +} diff --git a/packages/graphql/src/schema-model/entity/model-adapters/UnionEntityAdapter.ts b/packages/graphql/src/schema-model/entity/model-adapters/UnionEntityAdapter.ts new file mode 100644 index 0000000000..013c1c0dd8 --- /dev/null +++ b/packages/graphql/src/schema-model/entity/model-adapters/UnionEntityAdapter.ts @@ -0,0 +1,40 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ConcreteEntity } from "../ConcreteEntity"; +import type { UnionEntity } from "../UnionEntity"; +import { ConcreteEntityAdapter } from "./ConcreteEntityAdapter"; + +export class UnionEntityAdapter { + public readonly name: string; + public concreteEntities: ConcreteEntityAdapter[]; + + constructor(entity: UnionEntity) { + this.name = entity.name; + this.concreteEntities = []; + this.initConcreteEntities(entity.concreteEntities); + } + + private initConcreteEntities(entities: ConcreteEntity[]) { + for (const entity of entities) { + const entityAdapter = new ConcreteEntityAdapter(entity); + this.concreteEntities.push(entityAdapter); + } + } +} diff --git a/packages/graphql/src/schema-model/generate-model.test.ts b/packages/graphql/src/schema-model/generate-model.test.ts index 3998a18730..5ff8262396 100644 --- a/packages/graphql/src/schema-model/generate-model.test.ts +++ b/packages/graphql/src/schema-model/generate-model.test.ts @@ -32,6 +32,10 @@ import type { AttributeAdapter } from "./attribute/model-adapters/AttributeAdapt import type { ConcreteEntityAdapter } from "./entity/model-adapters/ConcreteEntityAdapter"; import type { RelationshipAdapter } from "./relationship/model-adapters/RelationshipAdapter"; import type { ConcreteEntity } from "./entity/ConcreteEntity"; +import { InterfaceEntity } from "./entity/InterfaceEntity"; +import { UnionEntity } from "./entity/UnionEntity"; + +// TODO: interface implementing interface annotations inheritance describe("Schema model generation", () => { test("parses @authentication directive with no arguments", () => { @@ -291,6 +295,13 @@ describe("ComposeEntity generation", () => { expect(humanEntities?.concreteEntities).toHaveLength(1); // User }); + test("composite entities has correct type", () => { + const toolEntities = schemaModel.compositeEntities.find((e) => e.name === "Tool"); + expect(toolEntities).toBeInstanceOf(UnionEntity); + const humanEntities = schemaModel.compositeEntities.find((e) => e.name === "Human"); + expect(humanEntities).toBeInstanceOf(InterfaceEntity); + }); + test("concrete entity has correct attributes", () => { const userEntity = schemaModel.concreteEntities.find((e) => e.name === "User"); expect(userEntity?.attributes.has("id")).toBeTrue(); @@ -312,19 +323,41 @@ describe("Relationship", () => { favoriteShow: [Show!]! @relationship(type: "FAVORITE_SHOW", direction: OUT) } + interface Person { + favoriteActors: [Actor!]! @relationship(type: "FAVORITE_ACTOR", direction: OUT) + } + + interface Human implements Person { + favoriteActors: [Actor!]! @relationship(type: "LIKES", direction: OUT) + } + + interface Worker implements Person { + favoriteActors: [Actor!]! + } + interface hasAccount @relationshipProperties { creationTime: DateTime! } union Show = Movie | TvShow - type Movie { + type Actor { + name: String + } + + interface Production { + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: OUT) + } + + type Movie implements Production { name: String! + actors: [Actor!]! } - type TvShow { + type TvShow implements Production { name: String! episodes: Int + actors: [Actor!]! @relationship(type: "STARED_IN", direction: OUT) } type Account { @@ -332,8 +365,9 @@ describe("Relationship", () => { username: String! } - extend type User { + extend type User implements Worker & Human & Person { password: String! @authorization(filter: [{ where: { node: { id: { equals: "$jwt.sub" } } } }]) + favoriteActors: [Actor!]! } `; @@ -359,9 +393,117 @@ describe("Relationship", () => { expect(accounts?.target.name).toBe("Account"); expect(accounts?.attributes.has("creationTime")).toBeTrue(); }); + + test("composite interface entity has correct relationship", () => { + const productionEntity = schemaModel.compositeEntities.find((e) => e.name === "Production") as InterfaceEntity; + const actors = productionEntity?.relationships.get("actors"); + expect(actors).toBeDefined(); + expect(actors?.type).toBe("ACTED_IN"); + expect(actors?.direction).toBe("OUT"); + expect(actors?.queryDirection).toBe("DEFAULT_DIRECTED"); + expect(actors?.nestedOperations).toEqual([ + "CREATE", + "UPDATE", + "DELETE", + "CONNECT", + "DISCONNECT", + "CONNECT_OR_CREATE", + ]); + expect(actors?.target.name).toBe("Actor"); + }); + + test("concrete entity has inherited relationship", () => { + const movieEntity = schemaModel.concreteEntities.find((e) => e.name === "Movie"); + const actors = movieEntity?.relationships.get("actors"); + expect(actors).toBeDefined(); + expect(actors?.type).toBe("ACTED_IN"); + expect(actors?.direction).toBe("OUT"); + expect(actors?.queryDirection).toBe("DEFAULT_DIRECTED"); + expect(actors?.nestedOperations).toEqual([ + "CREATE", + "UPDATE", + "DELETE", + "CONNECT", + "DISCONNECT", + "CONNECT_OR_CREATE", + ]); + expect(actors?.target.name).toBe("Actor"); + }); + + test("concrete entity has overwritten the inherited relationship", () => { + const showEntity = schemaModel.concreteEntities.find((e) => e.name === "TvShow"); + const actors = showEntity?.relationships.get("actors"); + expect(actors).toBeDefined(); + expect(actors?.type).toBe("STARED_IN"); + expect(actors?.direction).toBe("OUT"); + expect(actors?.queryDirection).toBe("DEFAULT_DIRECTED"); + expect(actors?.nestedOperations).toEqual([ + "CREATE", + "UPDATE", + "DELETE", + "CONNECT", + "DISCONNECT", + "CONNECT_OR_CREATE", + ]); + expect(actors?.target.name).toBe("Actor"); + }); + + test("composite entity has overwritten the inherited relationship", () => { + const humanEntity = schemaModel.compositeEntities.find((e) => e.name === "Human") as InterfaceEntity; + const actors = humanEntity?.relationships.get("favoriteActors"); + expect(actors).toBeDefined(); + expect(actors?.type).toBe("LIKES"); + expect(actors?.direction).toBe("OUT"); + expect(actors?.queryDirection).toBe("DEFAULT_DIRECTED"); + expect(actors?.nestedOperations).toEqual([ + "CREATE", + "UPDATE", + "DELETE", + "CONNECT", + "DISCONNECT", + "CONNECT_OR_CREATE", + ]); + expect(actors?.target.name).toBe("Actor"); + }); + + test("composite entity has inherited relationship", () => { + const workerEntity = schemaModel.compositeEntities.find((e) => e.name === "Worker") as InterfaceEntity; + const actors = workerEntity?.relationships.get("favoriteActors"); + expect(actors).toBeDefined(); + expect(actors?.type).toBe("FAVORITE_ACTOR"); + expect(actors?.direction).toBe("OUT"); + expect(actors?.queryDirection).toBe("DEFAULT_DIRECTED"); + expect(actors?.nestedOperations).toEqual([ + "CREATE", + "UPDATE", + "DELETE", + "CONNECT", + "DISCONNECT", + "CONNECT_OR_CREATE", + ]); + expect(actors?.target.name).toBe("Actor"); + }); + + test("concrete entity has inherited relationship of first implemented interface with defined relationship", () => { + const userEntity = schemaModel.concreteEntities.find((e) => e.name === "User"); + const actors = userEntity?.relationships.get("favoriteActors"); + expect(actors).toBeDefined(); + expect(actors?.type).toBe("LIKES"); + expect(actors?.direction).toBe("OUT"); + expect(actors?.queryDirection).toBe("DEFAULT_DIRECTED"); + expect(actors?.nestedOperations).toEqual([ + "CREATE", + "UPDATE", + "DELETE", + "CONNECT", + "DISCONNECT", + "CONNECT_OR_CREATE", + ]); + expect(actors?.target.name).toBe("Actor"); + }); }); -describe("Annotations & Attributes", () => { +describe("ConcreteEntity Annotations & Attributes", () => { let schemaModel: Neo4jGraphQLSchemaModel; let userEntity: ConcreteEntity; let accountEntity: ConcreteEntity; @@ -447,6 +589,158 @@ describe("Annotations & Attributes", () => { }); }); +describe("ComposeEntity Annotations & Attributes and Inheritance", () => { + test("concrete entity inherits from composite entity", () => { + const typeDefs = gql` + interface Production { + year: Int @populatedBy(callback: "thisCallback", operations: [CREATE]) + defaultName: String! @default(value: "AwesomeProduction") + aliasedProp: String! @alias(property: "dbName") + } + + type Movie implements Production { + name: String! + year: Int + defaultName: String! + aliasedProp: String! @alias(property: "movieDbName") + } + + type TvShow implements Production { + name: String! + episodes: Int + year: Int @populatedBy(callback: "thisOtherCallback", operations: [CREATE]) + aliasedProp: String! + } + + extend type TvShow { + defaultName: String! @default(value: "AwesomeShow") + } + `; + + const document = mergeTypeDefs(typeDefs); + const schemaModel = generateModel(document); + const movieEntity = schemaModel.concreteEntities.find((e) => e.name === "Movie") as ConcreteEntity; + const showEntity = schemaModel.concreteEntities.find((e) => e.name === "TvShow") as ConcreteEntity; + const productionEntity = schemaModel.compositeEntities.find((e) => e.name === "Production") as InterfaceEntity; + + const productionYear = productionEntity?.attributes.get("year"); + expect(productionYear?.annotations[AnnotationsKey.populatedBy]).toBeDefined(); + expect(productionYear?.annotations[AnnotationsKey.populatedBy]?.callback).toBe("thisCallback"); + expect(productionYear?.annotations[AnnotationsKey.populatedBy]?.operations).toStrictEqual(["CREATE"]); + + const movieYear = movieEntity?.attributes.get("year"); + expect(movieYear?.annotations[AnnotationsKey.populatedBy]).toBeDefined(); + expect(movieYear?.annotations[AnnotationsKey.populatedBy]?.callback).toBe("thisCallback"); + expect(movieYear?.annotations[AnnotationsKey.populatedBy]?.operations).toStrictEqual(["CREATE"]); + + const showYear = showEntity?.attributes.get("year"); + expect(showYear?.annotations[AnnotationsKey.populatedBy]).toBeDefined(); + expect(showYear?.annotations[AnnotationsKey.populatedBy]?.callback).toBe("thisOtherCallback"); + expect(showYear?.annotations[AnnotationsKey.populatedBy]?.operations).toStrictEqual(["CREATE"]); + + const productionDefaultName = productionEntity?.attributes.get("defaultName"); + expect(productionDefaultName).toBeDefined(); + expect(productionDefaultName?.annotations[AnnotationsKey.default]).toBeDefined(); + expect(productionDefaultName?.annotations[AnnotationsKey.default]?.value).toBe("AwesomeProduction"); + + const movieDefaultName = movieEntity?.attributes.get("defaultName"); + expect(movieDefaultName).toBeDefined(); + expect(movieDefaultName?.annotations[AnnotationsKey.default]).toBeDefined(); + expect(movieDefaultName?.annotations[AnnotationsKey.default]?.value).toBe("AwesomeProduction"); + + const showDefaultName = showEntity?.attributes.get("defaultName"); + expect(showDefaultName).toBeDefined(); + expect(showDefaultName?.annotations[AnnotationsKey.default]).toBeDefined(); + expect(showDefaultName?.annotations[AnnotationsKey.default]?.value).toBe("AwesomeShow"); + + const productionAliasedProp = productionEntity?.attributes.get("aliasedProp"); + const movieAliasedProp = movieEntity?.attributes.get("aliasedProp"); + const showAliasedProp = showEntity?.attributes.get("aliasedProp"); + expect(productionAliasedProp?.databaseName).toBeDefined(); + expect(productionAliasedProp?.databaseName).toBe("dbName"); + expect(movieAliasedProp?.databaseName).toBeDefined(); + expect(movieAliasedProp?.databaseName).toBe("movieDbName"); + expect(showAliasedProp?.databaseName).toBeDefined(); + expect(showAliasedProp?.databaseName).toBe("dbName"); + }); + + test("composite entity inherits from composite entity", () => { + const typeDefs = gql` + interface Production { + year: Int @populatedBy(callback: "thisCallback", operations: [CREATE]) + defaultName: String! @default(value: "AwesomeProduction") + aliasedProp: String! @alias(property: "dbName") + } + + interface TvProduction implements Production { + name: String! + year: Int + defaultName: String! + aliasedProp: String! @alias(property: "movieDbName") + } + + type TvShow implements TvProduction & Production { + name: String! + episodes: Int + year: Int @populatedBy(callback: "thisOtherCallback", operations: [CREATE]) + aliasedProp: String! + } + + extend type TvShow { + defaultName: String! @default(value: "AwesomeShow") + } + `; + + const document = mergeTypeDefs(typeDefs); + const schemaModel = generateModel(document); + const tvProductionEntity = schemaModel.compositeEntities.find( + (e) => e.name === "TvProduction" + ) as InterfaceEntity; + const showEntity = schemaModel.concreteEntities.find((e) => e.name === "TvShow") as ConcreteEntity; + const productionEntity = schemaModel.compositeEntities.find((e) => e.name === "Production") as InterfaceEntity; + + const productionYear = productionEntity?.attributes.get("year"); + expect(productionYear?.annotations[AnnotationsKey.populatedBy]).toBeDefined(); + expect(productionYear?.annotations[AnnotationsKey.populatedBy]?.callback).toBe("thisCallback"); + expect(productionYear?.annotations[AnnotationsKey.populatedBy]?.operations).toStrictEqual(["CREATE"]); + + const tvProductionYear = tvProductionEntity?.attributes.get("year"); + expect(tvProductionYear?.annotations[AnnotationsKey.populatedBy]).toBeDefined(); + expect(tvProductionYear?.annotations[AnnotationsKey.populatedBy]?.callback).toBe("thisCallback"); + expect(tvProductionYear?.annotations[AnnotationsKey.populatedBy]?.operations).toStrictEqual(["CREATE"]); + + const showYear = showEntity?.attributes.get("year"); + expect(showYear?.annotations[AnnotationsKey.populatedBy]).toBeDefined(); + expect(showYear?.annotations[AnnotationsKey.populatedBy]?.callback).toBe("thisOtherCallback"); + expect(showYear?.annotations[AnnotationsKey.populatedBy]?.operations).toStrictEqual(["CREATE"]); + + const productionDefaultName = productionEntity?.attributes.get("defaultName"); + expect(productionDefaultName).toBeDefined(); + expect(productionDefaultName?.annotations[AnnotationsKey.default]).toBeDefined(); + expect(productionDefaultName?.annotations[AnnotationsKey.default]?.value).toBe("AwesomeProduction"); + + const tvProductionDefaultName = tvProductionEntity?.attributes.get("defaultName"); + expect(tvProductionDefaultName).toBeDefined(); + expect(tvProductionDefaultName?.annotations[AnnotationsKey.default]).toBeDefined(); + expect(tvProductionDefaultName?.annotations[AnnotationsKey.default]?.value).toBe("AwesomeProduction"); + + const showDefaultName = showEntity?.attributes.get("defaultName"); + expect(showDefaultName).toBeDefined(); + expect(showDefaultName?.annotations[AnnotationsKey.default]).toBeDefined(); + expect(showDefaultName?.annotations[AnnotationsKey.default]?.value).toBe("AwesomeShow"); + + const productionAliasedProp = productionEntity?.attributes.get("aliasedProp"); + const tvProductionAliasedProp = tvProductionEntity?.attributes.get("aliasedProp"); + const showAliasedProp = showEntity?.attributes.get("aliasedProp"); + expect(productionAliasedProp?.databaseName).toBeDefined(); + expect(productionAliasedProp?.databaseName).toBe("dbName"); + expect(tvProductionAliasedProp?.databaseName).toBeDefined(); + expect(tvProductionAliasedProp?.databaseName).toBe("movieDbName"); + expect(showAliasedProp?.databaseName).toBeDefined(); + expect(showAliasedProp?.databaseName).toBe("movieDbName"); // first one listed in the implements list decides + }); +}); + describe("GraphQL adapters", () => { let schemaModel: Neo4jGraphQLSchemaModel; // entities diff --git a/packages/graphql/src/schema-model/generate-model.ts b/packages/graphql/src/schema-model/generate-model.ts index ef52cfd5ce..ff0bb860c7 100644 --- a/packages/graphql/src/schema-model/generate-model.ts +++ b/packages/graphql/src/schema-model/generate-model.ts @@ -16,7 +16,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { DirectiveNode, DocumentNode, FieldDefinitionNode, ObjectTypeDefinitionNode } from "graphql"; +import type { + DirectiveNode, + DocumentNode, + FieldDefinitionNode, + InterfaceTypeDefinitionNode, + ObjectTypeDefinitionNode, +} from "graphql"; import { Neo4jGraphQLSchemaValidationError } from "../classes"; import getFieldTypeMeta from "../schema/get-field-type-meta"; import { filterTruthy } from "../utils/utils"; @@ -24,7 +30,6 @@ import { Neo4jGraphQLSchemaModel } from "./Neo4jGraphQLSchemaModel"; import type { Operations } from "./Neo4jGraphQLSchemaModel"; import type { Annotation } from "./annotation/Annotation"; import type { Attribute } from "./attribute/Attribute"; -import { CompositeEntity } from "./entity/CompositeEntity"; import { ConcreteEntity } from "./entity/ConcreteEntity"; import { findDirective } from "./parser/utils"; import { parseArguments } from "./parser/parse-arguments"; @@ -37,6 +42,8 @@ import { parseAttribute, parseField } from "./parser/parse-attribute"; import { nodeDirective, relationshipDirective } from "../graphql/directives"; import { parseKeyAnnotation } from "./parser/annotations-parser/key-annotation"; import { parseAnnotations } from "./parser/parse-annotation"; +import { InterfaceEntity } from "./entity/InterfaceEntity"; +import { UnionEntity } from "./entity/UnionEntity"; export function generateModel(document: DocumentNode): Neo4jGraphQLSchemaModel { const definitionCollection: DefinitionCollection = getDefinitionCollection(document); @@ -63,11 +70,21 @@ export function generateModel(document: DocumentNode): Neo4jGraphQLSchemaModel { const interfaceEntities = Array.from(definitionCollection.interfaceToImplementingTypeNamesMap.entries()).map( ([name, concreteEntities]) => { - return generateCompositeEntity(name, concreteEntities, concreteEntitiesMap); + const interfaceNode = definitionCollection.interfaceTypes.get(name); + if (!interfaceNode) { + throw new Error(`Cannot find interface ${name}`); + } + return generateInterfaceEntity( + name, + interfaceNode, + concreteEntities, + concreteEntitiesMap, + definitionCollection + ); } ); const unionEntities = Array.from(definitionCollection.unionTypes).map(([unionName, unionDefinition]) => { - return generateCompositeEntity( + return generateUnionEntity( unionName, unionDefinition.types?.map((t) => t.name.value) || [], concreteEntitiesMap @@ -83,6 +100,8 @@ export function generateModel(document: DocumentNode): Neo4jGraphQLSchemaModel { annotations, }); definitionCollection.nodes.forEach((def) => hydrateRelationships(def, schema, definitionCollection)); + definitionCollection.interfaceTypes.forEach((def) => hydrateRelationships(def, schema, definitionCollection)); + return schema; } @@ -109,11 +128,66 @@ function hydrateInterfacesToTypeNamesMap(definitionCollection: DefinitionCollect }); } +function generateUnionEntity( + entityDefinitionName: string, + entityImplementingTypeNames: string[], + concreteEntities: Map +): UnionEntity { + const unionEntity = generateCompositeEntity(entityDefinitionName, entityImplementingTypeNames, concreteEntities); + return new UnionEntity(unionEntity); +} + +function generateInterfaceEntity( + entityDefinitionName: string, + definition: InterfaceTypeDefinitionNode, + entityImplementingTypeNames: string[], + concreteEntities: Map, + definitionCollection: DefinitionCollection +): InterfaceEntity { + const interfaceEntity = generateCompositeEntity( + entityDefinitionName, + entityImplementingTypeNames, + concreteEntities + ); + const inheritedFields = + definition.interfaces?.flatMap((interfaceNamedNode) => { + const interfaceName = interfaceNamedNode.name.value; + return definitionCollection.interfaceTypes.get(interfaceName)?.fields || []; + }) || []; + const fields = (definition.fields || []).map((fieldDefinition) => { + const inheritedField = inheritedFields?.filter( + (inheritedField) => inheritedField.name.value === fieldDefinition.name.value + ); + const isRelationshipAttribute = findDirective(fieldDefinition.directives, relationshipDirective.name); + const isInheritedRelationshipAttribute = inheritedField?.some((inheritedField) => + findDirective(inheritedField.directives, relationshipDirective.name) + ); + if (isRelationshipAttribute || isInheritedRelationshipAttribute) { + return; + } + return parseAttribute(fieldDefinition, inheritedField, definitionCollection); + }); + + const inheritedDirectives = + definition.interfaces?.flatMap((interfaceNamedNode) => { + const interfaceName = interfaceNamedNode.name.value; + return definitionCollection.interfaceTypes.get(interfaceName)?.directives || []; + }) || []; + const mergedDirectives = (definition.directives || []).concat(inheritedDirectives); + const annotations = createEntityAnnotations(mergedDirectives); + + return new InterfaceEntity({ + ...interfaceEntity, + attributes: filterTruthy(fields) as Attribute[], + annotations, + }); +} + function generateCompositeEntity( entityDefinitionName: string, entityImplementingTypeNames: string[], concreteEntities: Map -): CompositeEntity { +): { name: string; concreteEntities: ConcreteEntity[] } { const compositeFields = entityImplementingTypeNames.map((type) => { const concreteEntity = concreteEntities.get(type); if (!concreteEntity) { @@ -129,37 +203,62 @@ function generateCompositeEntity( `Composite entity ${entityDefinitionName} has no concrete entities` ); } */ - // TODO: add annotations - return new CompositeEntity({ + return { name: entityDefinitionName, concreteEntities: compositeFields, - }); + }; } function hydrateRelationships( - definition: ObjectTypeDefinitionNode, + definition: ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode, schema: Neo4jGraphQLSchemaModel, definitionCollection: DefinitionCollection ): void { const name = definition.name.value; const entity = schema.getEntity(name); - if (!schema.isConcreteEntity(entity)) { - throw new Error(`Cannot add relationship to non-concrete entity ${name}`); + if (!entity) { + throw new Error(`Cannot find entity ${name}`); + } + if (entity instanceof UnionEntity) { + throw new Error(`Cannot add relationship to union entity ${name}`); + } + // TODO: fix ts + const entityWithRelationships: ConcreteEntity | InterfaceEntity = entity as ConcreteEntity | InterfaceEntity; + const inheritedFields = + definition.interfaces?.flatMap((interfaceNamedNode) => { + const interfaceName = interfaceNamedNode.name.value; + return definitionCollection.interfaceTypes.get(interfaceName)?.fields || []; + }) || []; + // TODO: directives on definition have priority over interfaces + const mergedFields = (definition.fields || []).concat(inheritedFields); + const relationshipFieldsMap = new Map(); + for (const fieldDefinition of mergedFields) { + // TODO: takes the first one + // multiple interfaces can have this annotation - must constrain this flexibility by design + if (relationshipFieldsMap.has(fieldDefinition.name.value)) { + continue; + } + const relationshipField = generateRelationshipField( + fieldDefinition, + schema, + entityWithRelationships, + definitionCollection + ); + if (relationshipField) { + relationshipFieldsMap.set(fieldDefinition.name.value, relationshipField); + } } - const relationshipFields = (definition.fields || []).map((fieldDefinition) => { - return generateRelationshipField(fieldDefinition, schema, entity, definitionCollection); - }); - for (const relationship of filterTruthy(relationshipFields)) { - entity.addRelationship(relationship); + for (const relationship of relationshipFieldsMap.values()) { + entityWithRelationships.addRelationship(relationship); } } function generateRelationshipField( field: FieldDefinitionNode, schema: Neo4jGraphQLSchemaModel, - source: ConcreteEntity, + source: ConcreteEntity | InterfaceEntity, definitionCollection: DefinitionCollection ): Relationship | undefined { const fieldTypeMeta = getFieldTypeMeta(field.type); @@ -184,7 +283,17 @@ function generateRelationshipField( ); } - const fields = (propertyInterface.fields || []).map((field) => parseAttribute(field, definitionCollection)); + const inheritedFields = + propertyInterface.interfaces?.flatMap((interfaceNamedNode) => { + const interfaceName = interfaceNamedNode.name.value; + return definitionCollection.interfaceTypes.get(interfaceName)?.fields || []; + }) || []; + const fields = (propertyInterface.fields || []).map((fieldDefinition) => { + const inheritedField = inheritedFields?.filter( + (inheritedField) => inheritedField.name.value === fieldDefinition.name.value + ); + return parseAttribute(fieldDefinition, inheritedField, definitionCollection); + }); attributes = filterTruthy(fields) as Attribute[]; } @@ -206,13 +315,26 @@ function generateConcreteEntity( definition: ObjectTypeDefinitionNode, definitionCollection: DefinitionCollection ): ConcreteEntity { - const fields = (definition.fields || []).map((fieldDefinition) => - parseAttribute(fieldDefinition, definitionCollection) - ); + const inheritedFields = definition.interfaces?.flatMap((interfaceNamedNode) => { + const interfaceName = interfaceNamedNode.name.value; + return definitionCollection.interfaceTypes.get(interfaceName)?.fields || []; + }); + const fields = (definition.fields || []).map((fieldDefinition) => { + const inheritedField = inheritedFields?.filter( + (inheritedField) => inheritedField.name.value === fieldDefinition.name.value + ); + const isRelationshipAttribute = findDirective(fieldDefinition.directives, relationshipDirective.name); + const isInheritedRelationshipAttribute = inheritedField?.some((inheritedField) => + findDirective(inheritedField.directives, relationshipDirective.name) + ); + if (isRelationshipAttribute || isInheritedRelationshipAttribute) { + return; + } + return parseAttribute(fieldDefinition, inheritedField, definitionCollection); + }); const annotations = createEntityAnnotations(definition.directives || []); - // TODO: add annotations inherited from interface return new ConcreteEntity({ name: definition.name.value, labels: getLabels(definition), @@ -235,6 +357,8 @@ function getLabels(entityDefinition: ObjectTypeDefinitionNode): string[] { function createEntityAnnotations(directives: readonly DirectiveNode[]): Annotation[] { const entityAnnotations: Annotation[] = []; + // TODO: I think this is done already with the map change and we do not have repeatable directives + // We only ever want to create one annotation even when an entity contains several key directives const keyDirectives = directives.filter((directive) => directive.name.value === "key"); if (keyDirectives) { diff --git a/packages/graphql/src/schema-model/parser/parse-annotation.ts b/packages/graphql/src/schema-model/parser/parse-annotation.ts index 47be28ccef..f96cc89a95 100644 --- a/packages/graphql/src/schema-model/parser/parse-annotation.ts +++ b/packages/graphql/src/schema-model/parser/parse-annotation.ts @@ -40,64 +40,75 @@ import { parseJWTPayloadAnnotation } from "./annotations-parser/jwt-payload-anno import { parseAuthorizationAnnotation } from "./annotations-parser/authorization-annotation"; import { parseAuthenticationAnnotation } from "./annotations-parser/authentication-annotation"; import { parseSubscriptionsAuthorizationAnnotation } from "./annotations-parser/subscriptions-authorization-annotation"; -import { filterTruthy } from "../../utils/utils"; import type { Annotation } from "../annotation/Annotation"; import { AnnotationsKey } from "../annotation/Annotation"; import { IDAnnotation } from "../annotation/IDAnnotation"; export function parseAnnotations(directives: readonly DirectiveNode[]): Annotation[] { - return filterTruthy( - directives.map((directive) => { - switch (directive.name.value) { - case AnnotationsKey.authentication: - return parseAuthenticationAnnotation(directive); - case AnnotationsKey.authorization: - return parseAuthorizationAnnotation(directive); - case AnnotationsKey.coalesce: - return parseCoalesceAnnotation(directive); - case AnnotationsKey.customResolver: - return parseCustomResolverAnnotation(directive); - case AnnotationsKey.cypher: - return parseCypherAnnotation(directive); - case AnnotationsKey.default: - return parseDefaultAnnotation(directive); - case AnnotationsKey.filterable: - return parseFilterableAnnotation(directive); - case AnnotationsKey.fulltext: - return parseFullTextAnnotation(directive); - case AnnotationsKey.id: - return new IDAnnotation(); - case AnnotationsKey.jwtClaim: - return parseJWTClaimAnnotation(directive); - case AnnotationsKey.jwtPayload: - return parseJWTPayloadAnnotation(directive); - case AnnotationsKey.mutation: - return parseMutationAnnotation(directive); - case AnnotationsKey.plural: - return parsePluralAnnotation(directive); - case AnnotationsKey.populatedBy: - return parsePopulatedByAnnotation(directive); - case AnnotationsKey.private: - return parsePrivateAnnotation(directive); - case AnnotationsKey.query: - return parseQueryAnnotation(directive); - case AnnotationsKey.limit: - return parseLimitAnnotation(directive); - case AnnotationsKey.selectable: - return parseSelectableAnnotation(directive); - case AnnotationsKey.settable: - return parseSettableAnnotation(directive); - case AnnotationsKey.subscription: - return parseSubscriptionAnnotation(directive); - case AnnotationsKey.subscriptionsAuthorization: - return parseSubscriptionsAuthorizationAnnotation(directive); - case AnnotationsKey.timestamp: - return parseTimestampAnnotation(directive); - case AnnotationsKey.unique: - return parseUniqueAnnotation(directive); - default: - return undefined; - } - }) - ); + const annotations = directives.reduce((directivesMap, directive) => { + if (directivesMap.has(directive.name.value)) { + // TODO: takes the first one + // multiple interfaces can have this annotation - must constrain this flexibility by design + return directivesMap; + } + const annotation = parseDirective(directive); + if (annotation) { + directivesMap.set(directive.name.value, annotation); + } + return directivesMap; + }, new Map()); + return Array.from(annotations.values()); +} + +function parseDirective(directive: DirectiveNode): Annotation | undefined { + switch (directive.name.value) { + case AnnotationsKey.authentication: + return parseAuthenticationAnnotation(directive); + case AnnotationsKey.authorization: + return parseAuthorizationAnnotation(directive); + case AnnotationsKey.coalesce: + return parseCoalesceAnnotation(directive); + case AnnotationsKey.customResolver: + return parseCustomResolverAnnotation(directive); + case AnnotationsKey.cypher: + return parseCypherAnnotation(directive); + case AnnotationsKey.default: + return parseDefaultAnnotation(directive); + case AnnotationsKey.filterable: + return parseFilterableAnnotation(directive); + case AnnotationsKey.fulltext: + return parseFullTextAnnotation(directive); + case AnnotationsKey.id: + return new IDAnnotation(); + case AnnotationsKey.jwtClaim: + return parseJWTClaimAnnotation(directive); + case AnnotationsKey.jwtPayload: + return parseJWTPayloadAnnotation(directive); + case AnnotationsKey.mutation: + return parseMutationAnnotation(directive); + case AnnotationsKey.plural: + return parsePluralAnnotation(directive); + case AnnotationsKey.populatedBy: + return parsePopulatedByAnnotation(directive); + case AnnotationsKey.private: + return parsePrivateAnnotation(directive); + case AnnotationsKey.query: + return parseQueryAnnotation(directive); + case AnnotationsKey.limit: + return parseLimitAnnotation(directive); + case AnnotationsKey.selectable: + return parseSelectableAnnotation(directive); + case AnnotationsKey.settable: + return parseSettableAnnotation(directive); + case AnnotationsKey.subscription: + return parseSubscriptionAnnotation(directive); + case AnnotationsKey.subscriptionsAuthorization: + return parseSubscriptionsAuthorizationAnnotation(directive); + case AnnotationsKey.timestamp: + return parseTimestampAnnotation(directive); + case AnnotationsKey.unique: + return parseUniqueAnnotation(directive); + default: + return undefined; + } } diff --git a/packages/graphql/src/schema-model/parser/parse-attribute.ts b/packages/graphql/src/schema-model/parser/parse-attribute.ts index 7112405bce..c440a3fbe6 100644 --- a/packages/graphql/src/schema-model/parser/parse-attribute.ts +++ b/packages/graphql/src/schema-model/parser/parse-attribute.ts @@ -17,7 +17,7 @@ * limitations under the License. */ -import type { FieldDefinitionNode, TypeNode } from "graphql"; +import type { DirectiveNode, FieldDefinitionNode, TypeNode } from "graphql"; import { Kind } from "graphql"; import type { AttributeType, Neo4jGraphQLScalarType } from "../attribute/AttributeType"; import { @@ -45,12 +45,14 @@ import { findDirective } from "./utils"; export function parseAttribute( field: FieldDefinitionNode, + inheritedField: FieldDefinitionNode[] | undefined, definitionCollection: DefinitionCollection ): Attribute | Field { const name = field.name.value; const type = parseTypeNode(definitionCollection, field.type); - const annotations = parseAnnotations(field.directives || []); - const databaseName = getDatabaseName(field); + const inheritedDirectives = inheritedField?.flatMap((f) => f.directives || []) || []; + const annotations = parseAnnotations((field.directives || []).concat(inheritedDirectives)); + const databaseName = getDatabaseName(field, inheritedField); return new Attribute({ name, annotations, @@ -59,12 +61,27 @@ export function parseAttribute( }); } -function getDatabaseName(fieldDefinitionNode: FieldDefinitionNode): string | undefined { +function getDatabaseName( + fieldDefinitionNode: FieldDefinitionNode, + inheritedFields: FieldDefinitionNode[] | undefined +): string | undefined { const aliasUsage = findDirective(fieldDefinitionNode.directives, aliasDirective.name); if (aliasUsage) { const { property } = parseArguments(aliasDirective, aliasUsage) as { property: string }; return property; } + const inheritedAliasUsage = inheritedFields?.reduce((aliasUsage, field) => { + // TODO: takes the first one + // multiple interfaces can have this annotation - must constrain this flexibility by design + if (!aliasUsage) { + aliasUsage = findDirective(field.directives, aliasDirective.name); + } + return aliasUsage; + }, undefined); + if (inheritedAliasUsage) { + const { property } = parseArguments(aliasDirective, inheritedAliasUsage) as { property: string }; + return property; + } } // we may want to remove Fields from the schema model diff --git a/packages/graphql/src/schema-model/relationship/Relationship.ts b/packages/graphql/src/schema-model/relationship/Relationship.ts index fbc9df3982..e0f90a9bd6 100644 --- a/packages/graphql/src/schema-model/relationship/Relationship.ts +++ b/packages/graphql/src/schema-model/relationship/Relationship.ts @@ -20,7 +20,6 @@ import { Neo4jGraphQLSchemaValidationError } from "../../classes"; import { upperFirst } from "../../utils/upper-first"; import type { Attribute } from "../attribute/Attribute"; -import type { ConcreteEntity } from "../entity/ConcreteEntity"; import type { Entity } from "../entity/Entity"; export type RelationshipDirection = "IN" | "OUT"; @@ -31,7 +30,7 @@ export class Relationship { public readonly name: string; // name of the relationship field, e.g. friends public readonly type: string; // name of the relationship type, e.g. "IS_FRIENDS_WITH" public readonly attributes: Map = new Map(); - public readonly source: ConcreteEntity; + public readonly source: Entity; public readonly target: Entity; public readonly direction: RelationshipDirection; public readonly isList: boolean; @@ -65,7 +64,7 @@ export class Relationship { name: string; type: string; attributes?: Attribute[]; - source: ConcreteEntity; + source: Entity; target: Entity; direction: RelationshipDirection; isList: boolean; diff --git a/packages/graphql/src/schema-model/relationship/model-adapters/RelationshipAdapter.ts b/packages/graphql/src/schema-model/relationship/model-adapters/RelationshipAdapter.ts index 65ed42f4b5..5fc2877e6e 100644 --- a/packages/graphql/src/schema-model/relationship/model-adapters/RelationshipAdapter.ts +++ b/packages/graphql/src/schema-model/relationship/model-adapters/RelationshipAdapter.ts @@ -24,15 +24,18 @@ import type { NestedOperation, QueryDirection, Relationship, RelationshipDirecti import { AttributeAdapter } from "../../attribute/model-adapters/AttributeAdapter"; import type { Attribute } from "../../attribute/Attribute"; import { ConcreteEntity } from "../../entity/ConcreteEntity"; -import { CompositeEntity } from "../../entity/CompositeEntity"; +import { InterfaceEntity } from "../../entity/InterfaceEntity"; +import { UnionEntity } from "../../entity/UnionEntity"; +import { UnionEntityAdapter } from "../../entity/model-adapters/UnionEntityAdapter"; +import { InterfaceEntityAdapter } from "../../entity/model-adapters/InterfaceEntityAdapter"; export class RelationshipAdapter { public readonly name: string; public readonly type: string; public readonly attributes: Map = new Map(); - public readonly source: ConcreteEntityAdapter; + public readonly source: ConcreteEntityAdapter | InterfaceEntityAdapter | UnionEntityAdapter; private rawEntity: Entity; - private _target: Entity | undefined; + private _target: ConcreteEntityAdapter | InterfaceEntityAdapter | UnionEntityAdapter | undefined; public readonly direction: RelationshipDirection; public readonly queryDirection: QueryDirection; public readonly nestedOperations: NestedOperation[]; @@ -58,7 +61,10 @@ export class RelationshipAdapter { )}${nestedFieldStr}${aggregationStr}Selection`; } - constructor(relationship: Relationship, sourceAdapter?: ConcreteEntityAdapter) { + constructor( + relationship: Relationship, + sourceAdapter?: ConcreteEntityAdapter | InterfaceEntityAdapter | UnionEntityAdapter + ) { const { name, type, @@ -76,7 +82,15 @@ export class RelationshipAdapter { if (sourceAdapter) { this.source = sourceAdapter; } else { - this.source = new ConcreteEntityAdapter(source); + if (source instanceof ConcreteEntity) { + this.source = new ConcreteEntityAdapter(source); + } else if (source instanceof InterfaceEntity) { + this.source = new InterfaceEntityAdapter(source); + } else if (source instanceof UnionEntity) { + this.source = new UnionEntityAdapter(source); + } else { + throw new Error("relationship source must be an Entity"); + } } this.direction = direction; this.isList = isList; @@ -131,12 +145,14 @@ export class RelationshipAdapter { } // construct the target entity only when requested - get target(): Entity { + get target(): ConcreteEntityAdapter | InterfaceEntityAdapter | UnionEntityAdapter { if (!this._target) { if (this.rawEntity instanceof ConcreteEntity) { this._target = new ConcreteEntityAdapter(this.rawEntity); - } else if (this.rawEntity instanceof CompositeEntity) { - this._target = new CompositeEntity(this.rawEntity); + } else if (this.rawEntity instanceof InterfaceEntity) { + this._target = new InterfaceEntityAdapter(this.rawEntity); + } else if (this.rawEntity instanceof UnionEntity) { + this._target = new UnionEntityAdapter(this.rawEntity); } else { throw new Error("invalid target entity type"); } diff --git a/packages/graphql/src/schema/resolvers/subscriptions/authentication/selection-set-parser.ts b/packages/graphql/src/schema/resolvers/subscriptions/authentication/selection-set-parser.ts index aad93a2d58..9ff529c98a 100644 --- a/packages/graphql/src/schema/resolvers/subscriptions/authentication/selection-set-parser.ts +++ b/packages/graphql/src/schema/resolvers/subscriptions/authentication/selection-set-parser.ts @@ -109,10 +109,10 @@ function getTargetEntities({ if (!relationshipTarget) { return; } - if (context.schemaModel.isConcreteEntity(relationshipTarget)) { + if (relationshipTarget.isConcreteEntity()) { return [relationshipTarget]; } - if (context.schemaModel.isCompositeEntity(relationshipTarget)) { + if (relationshipTarget.isCompositeEntity()) { return relationshipTarget.concreteEntities; } } diff --git a/packages/graphql/src/translate/projection/subquery/translate-cypher-directive-projection.ts b/packages/graphql/src/translate/projection/subquery/translate-cypher-directive-projection.ts index f332571a63..4023a9efbf 100644 --- a/packages/graphql/src/translate/projection/subquery/translate-cypher-directive-projection.ts +++ b/packages/graphql/src/translate/projection/subquery/translate-cypher-directive-projection.ts @@ -22,7 +22,6 @@ import type { Node } from "../../../classes"; import type { GraphQLSortArg, CypherField, CypherFieldReferenceMap } from "../../../types"; import Cypher from "@neo4j/cypher-builder"; import createProjectionAndParams from "../../create-projection-and-params"; -import { CompositeEntity } from "../../../schema-model/entity/CompositeEntity"; import { compileCypher } from "../../../utils/compile-cypher"; import type { Neo4jGraphQLTranslationContext } from "../../../types/neo4j-graphql-translation-context"; @@ -93,7 +92,7 @@ export function translateCypherDirectiveProjection({ res.params = { ...res.params, ...p }; subqueries.push(...nestedSubqueriesBeforeSort, ...nestedSubqueries); predicates.push(...nestedPredicates); - } else if (entity instanceof CompositeEntity) { + } else if (entity?.isCompositeEntity()) { const unionProjections: Array<{ predicate: Cypher.Predicate; projection: Cypher.Expr }> = []; const labelsSubPredicates: Cypher.Predicate[] = []; diff --git a/packages/graphql/src/translate/translate-top-level-cypher.ts b/packages/graphql/src/translate/translate-top-level-cypher.ts index c0da5d9221..b6adbcfe36 100644 --- a/packages/graphql/src/translate/translate-top-level-cypher.ts +++ b/packages/graphql/src/translate/translate-top-level-cypher.ts @@ -21,7 +21,6 @@ import createProjectionAndParams from "./create-projection-and-params"; import type { CypherField } from "../types"; import { AUTH_FORBIDDEN_ERROR, AUTHORIZATION_UNAUTHENTICATED } from "../constants"; import Cypher from "@neo4j/cypher-builder"; -import { CompositeEntity } from "../schema-model/entity/CompositeEntity"; import { Neo4jGraphQLError } from "../classes"; import { filterByValues } from "./authorization/utils/filter-by-values"; import { compileCypher } from "../utils/compile-cypher"; @@ -105,7 +104,7 @@ export function translateTopLevelCypher({ const entity = context.schemaModel.entities.get(field.typeMeta.name); - if (entity instanceof CompositeEntity) { + if (entity?.isCompositeEntity()) { const headStrs: Cypher.Clause[] = []; const referencedNodes = entity.concreteEntities @@ -228,7 +227,7 @@ export function translateTopLevelCypher({ if (field.isScalar || field.isEnum) { cypherStrs.push(`RETURN this`); - } else if (entity instanceof CompositeEntity) { + } else if (entity?.isCompositeEntity()) { cypherStrs.push(`RETURN head( ${projectionStr.getCypher(env)} ) AS this`); } else { cypherStrs.push(`RETURN this ${projectionStr.getCypher(env)} AS this`); diff --git a/packages/graphql/tests/e2e/subscriptions/authorization/authentication.int.test.ts b/packages/graphql/tests/e2e/subscriptions/authorization/authentication.int.test.ts index 490497bb37..7290c621dd 100644 --- a/packages/graphql/tests/e2e/subscriptions/authorization/authentication.int.test.ts +++ b/packages/graphql/tests/e2e/subscriptions/authorization/authentication.int.test.ts @@ -3598,7 +3598,7 @@ describe("Subscription authentication", () => { expect(wsClient.events).toEqual([]); expect(wsClient.errors).toEqual([expect.objectContaining({ message: "Unauthenticated" })]); }); - test("unauthenticated subscription sends events if interface field queried on unauthenticated implementing type - create_relationship", async () => { + test("unauthenticated subscription does not send events if interface field queried on unauthenticated implementing type - create_relationship", async () => { wsClient = new WebSocketTestClient(server.wsPath); await wsClient.subscribe(` subscription SubscriptionMovie { @@ -3658,25 +3658,10 @@ describe("Subscription authentication", () => { await wsClient.waitForEvents(1); expect(result.body.errors).toBeUndefined(); - expect(wsClient.events).toIncludeSameMembers([ - { - [typeMovie.operations.subscribe.relationship_created]: { - event: "CREATE_RELATIONSHIP", - relationshipFieldName: "reviewers", - createdRelationship: { - reviewers: { - score: 10, - node: { - name: "Bob", - reputation: 10, - }, - }, - }, - }, - }, - ]); - expect(wsClient.errors).toEqual([]); + expect(wsClient.events).toEqual([]); + expect(wsClient.errors).toEqual([expect.objectContaining({ message: "Unauthenticated" })]); }); + test("unauthenticated subscription sends events if no authenticated field queried - create_relationship", async () => { wsClient = new WebSocketTestClient(server.wsPath); await wsClient.subscribe(` @@ -4180,7 +4165,7 @@ describe("Subscription authentication", () => { expect(wsClient.events).toEqual([]); expect(wsClient.errors).toEqual([expect.objectContaining({ message: "Unauthenticated" })]); }); - test("unauthenticated subscription sends events if interface field queried on unauthenticated implementing type - delete_relationship", async () => { + test("unauthenticated subscription does not send events if interface field queried on unauthenticated implementing type - delete_relationship", async () => { await supertest(server.path) .post("") .send({ @@ -4269,24 +4254,9 @@ describe("Subscription authentication", () => { expect(result.body.errors).toBeUndefined(); await wsClient.waitForEvents(1); - expect(wsClient.events).toIncludeSameMembers([ - { - [typeMovie.operations.subscribe.relationship_deleted]: { - event: "DELETE_RELATIONSHIP", - relationshipFieldName: "reviewers", - deletedRelationship: { - reviewers: { - score: 10, - node: { - name: "Bob", - reputation: 10, - }, - }, - }, - }, - }, - ]); - expect(wsClient.errors).toEqual([]); + expect(result.body.errors).toBeUndefined(); + expect(wsClient.events).toEqual([]); + expect(wsClient.errors).toEqual([expect.objectContaining({ message: "Unauthenticated" })]); }); test("unauthenticated subscription sends events if no authenticated field queried - delete_relationship", async () => { await supertest(server.path)