diff --git a/.changeset/loud-phones-march.md b/.changeset/loud-phones-march.md new file mode 100644 index 0000000000..7c45f04987 --- /dev/null +++ b/.changeset/loud-phones-march.md @@ -0,0 +1,69 @@ +--- +"@neo4j/graphql": major +--- + +There have been major changes to the way that full-text search operates. + +The directive now requires the specification of an index name, query name, and indexed fields. + +```graphql +input FulltextInput { + indexName: String! + queryName: String! + fields: [String]! +} + +""" +Informs @neo4j/graphql that there should be a fulltext index in the database, allows users to search by the index in the generated schema. +""" +directive @fulltext(indexes: [FulltextInput]!) on OBJECT +``` + +Here is an example of how this might be used: + +```graphql +type Movie @node @fulltext(indexName: "movieTitleIndex", queryName: "moviesByTitle", fields: ["title"]) { + title: String! +} +``` + +Full-text search was previously available in two different locations. + +The following form has now been completely removed: + +```graphql +# Removed +{ + movies(fulltext: { movieTitleIndex: { phrase: "The Matrix" } }) { + title + } +} +``` + +The following form as a root-level query has been changed: + +```graphql +# Old query +query { + moviesByTitle(phrase: "The Matrix") { + score + movies { + title + } + } +} + +# New query +query { + moviesByTitle(phrase: "The Matrix") { + edges { + score + node { + title + } + } + } +} +``` + +The new form is as a Relay connection, which allows for pagination using cursors and access to the `pageInfo` field. diff --git a/packages/graphql/src/classes/Node.ts b/packages/graphql/src/classes/Node.ts index 810b42369f..eab5d1636f 100644 --- a/packages/graphql/src/classes/Node.ts +++ b/packages/graphql/src/classes/Node.ts @@ -116,7 +116,6 @@ class Node extends GraphElement { public interfaces: NamedTypeNode[]; public objectFields: ObjectField[]; public nodeDirective?: NodeDirective; - public fulltextDirective?: FullText; public description?: string; public limit?: LimitDirective; public singular: string; @@ -137,7 +136,6 @@ class Node extends GraphElement { this.interfaces = input.interfaces; this.objectFields = input.objectFields; this.nodeDirective = input.nodeDirective; - this.fulltextDirective = input.fulltextDirective; this.limit = input.limitDirective; this.isGlobalNode = input.isGlobalNode; this._idField = input.globalIdField; diff --git a/packages/graphql/src/classes/utils/asserts-indexes-and-constraints.ts b/packages/graphql/src/classes/utils/asserts-indexes-and-constraints.ts index ca1a386f19..0c5d5376e0 100644 --- a/packages/graphql/src/classes/utils/asserts-indexes-and-constraints.ts +++ b/packages/graphql/src/classes/utils/asserts-indexes-and-constraints.ts @@ -150,10 +150,7 @@ async function checkIndexesAndConstraints({ for (const entity of schemaModel.concreteEntities) { if (entity.annotations.fulltext) { entity.annotations.fulltext.indexes.forEach((index) => { - const indexName = index.indexName || index.name; // TODO remove indexName assignment and undefined check once the name argument has been removed. - if (indexName === undefined) { - throw new Error("The name of the fulltext index should be defined using the indexName argument."); - } + const indexName = index.indexName; const existingIndex = existingIndexes[indexName]; if (!existingIndex) { diff --git a/packages/graphql/src/graphql/directives/fulltext.ts b/packages/graphql/src/graphql/directives/fulltext.ts index be6959f02f..81178e93bd 100644 --- a/packages/graphql/src/graphql/directives/fulltext.ts +++ b/packages/graphql/src/graphql/directives/fulltext.ts @@ -26,11 +26,6 @@ import { GraphQLString, } from "graphql"; -const deprecationReason = - "The name argument has been deprecated and will be removed in future versions " + - "Please use indexName instead. More information about the changes to @fulltext can be found at " + - "https://neo4j.com/docs/graphql-manual/current/guides/v4-migration/#_fulltext_changes."; - export const fulltextDirective = new GraphQLDirective({ name: "fulltext", description: @@ -40,12 +35,8 @@ export const fulltextDirective = new GraphQLDirective({ type: new GraphQLNonNull( new GraphQLList( new GraphQLInputObjectType({ - name: "FullTextInput", + name: "FulltextInput", fields: { - name: { - deprecationReason, - type: GraphQLString, - }, fields: { type: new GraphQLNonNull(new GraphQLList(GraphQLString)), }, diff --git a/packages/graphql/src/schema-model/annotation/Annotation.ts b/packages/graphql/src/schema-model/annotation/Annotation.ts index fba2968888..25751e187c 100644 --- a/packages/graphql/src/schema-model/annotation/Annotation.ts +++ b/packages/graphql/src/schema-model/annotation/Annotation.ts @@ -25,7 +25,7 @@ import { parseCustomResolverAnnotation } from "../parser/annotations-parser/cust import { parseCypherAnnotation } from "../parser/annotations-parser/cypher-annotation"; import { parseDefaultAnnotation } from "../parser/annotations-parser/default-annotation"; import { parseFilterableAnnotation } from "../parser/annotations-parser/filterable-annotation"; -import { parseFullTextAnnotation } from "../parser/annotations-parser/full-text-annotation"; +import { parseFulltextAnnotation } from "../parser/annotations-parser/full-text-annotation"; import { parseJWTClaimAnnotation } from "../parser/annotations-parser/jwt-claim-annotation"; import { parseKeyAnnotation } from "../parser/annotations-parser/key-annotation"; import { parseLimitAnnotation } from "../parser/annotations-parser/limit-annotation"; @@ -46,7 +46,7 @@ import type { CustomResolverAnnotation } from "./CustomResolverAnnotation"; import type { CypherAnnotation } from "./CypherAnnotation"; import type { DefaultAnnotation } from "./DefaultAnnotation"; import type { FilterableAnnotation } from "./FilterableAnnotation"; -import type { FullTextAnnotation } from "./FullTextAnnotation"; +import type { FulltextAnnotation } from "./FulltextAnnotation"; import { IDAnnotation } from "./IDAnnotation"; import type { JWTClaimAnnotation } from "./JWTClaimAnnotation"; import { JWTPayloadAnnotation } from "./JWTPayloadAnnotation"; @@ -82,7 +82,7 @@ export type Annotations = CheckAnnotationName<{ cypher: CypherAnnotation; default: DefaultAnnotation; filterable: FilterableAnnotation; - fulltext: FullTextAnnotation; + fulltext: FulltextAnnotation; vector: VectorAnnotation; id: IDAnnotation; jwt: JWTPayloadAnnotation; @@ -115,7 +115,7 @@ export const annotationsParsers: { [key in keyof Annotations]: AnnotationParser< cypher: parseCypherAnnotation, default: parseDefaultAnnotation, filterable: parseFilterableAnnotation, - fulltext: parseFullTextAnnotation, + fulltext: parseFulltextAnnotation, id: () => new IDAnnotation(), jwtClaim: parseJWTClaimAnnotation, jwt: () => new JWTPayloadAnnotation(), diff --git a/packages/graphql/src/schema-model/annotation/FullTextAnnotation.ts b/packages/graphql/src/schema-model/annotation/FulltextAnnotation.ts similarity index 77% rename from packages/graphql/src/schema-model/annotation/FullTextAnnotation.ts rename to packages/graphql/src/schema-model/annotation/FulltextAnnotation.ts index 66a132019c..10bcee60c8 100644 --- a/packages/graphql/src/schema-model/annotation/FullTextAnnotation.ts +++ b/packages/graphql/src/schema-model/annotation/FulltextAnnotation.ts @@ -19,18 +19,17 @@ import type { Annotation } from "./Annotation"; -export type FullTextField = { - name?: string; - fields: string[]; - queryName?: string; +export type FulltextField = { indexName: string; + queryName: string; + fields: string[]; }; -export class FullTextAnnotation implements Annotation { +export class FulltextAnnotation implements Annotation { readonly name = "fulltext"; - public readonly indexes: FullTextField[]; + public readonly indexes: FulltextField[]; - constructor({ indexes }: { indexes: FullTextField[] }) { + constructor({ indexes }: { indexes: FulltextField[] }) { this.indexes = indexes; } } diff --git a/packages/graphql/src/schema-model/entity/model-adapters/ConcreteEntityOperations.ts b/packages/graphql/src/schema-model/entity/model-adapters/ConcreteEntityOperations.ts index 83c3956231..a0ed59c0b7 100644 --- a/packages/graphql/src/schema-model/entity/model-adapters/ConcreteEntityOperations.ts +++ b/packages/graphql/src/schema-model/entity/model-adapters/ConcreteEntityOperations.ts @@ -17,7 +17,6 @@ * limitations under the License. */ -import { upperFirst } from "../../../utils/upper-first"; import type { ConcreteEntityAdapter } from "./ConcreteEntityAdapter"; import type { RootTypeFieldNames as ImplementingTypeRootTypeFieldNames } from "./ImplementingEntityOperations"; import { ImplementingEntityOperations } from "./ImplementingEntityOperations"; @@ -31,13 +30,7 @@ type RootTypeFieldNames = ImplementingTypeRootTypeFieldNames & { }; }; -type FulltextTypeNames = { - result: string; - where: string; - sort: string; -}; - -type VectorTypeNames = { +type IndexTypeNames = { connection: string; edge: string; where: string; @@ -49,18 +42,6 @@ export class ConcreteEntityOperations extends ImplementingEntityOperations { it("should parse correctly", () => { @@ -29,7 +29,7 @@ describe("parseFullTextAnnotation", () => { { indexes: [{ indexName: "ProductName", fields: ["name"] }] }, fulltextDirective ); - const fullTextAnnotation = parseFullTextAnnotation(directive); + const fullTextAnnotation = parseFulltextAnnotation(directive); expect(fullTextAnnotation).toEqual({ name: "fulltext", indexes: [ diff --git a/packages/graphql/src/schema-model/parser/annotations-parser/full-text-annotation.ts b/packages/graphql/src/schema-model/parser/annotations-parser/full-text-annotation.ts index 9d0f60ad8c..f5acb360c5 100644 --- a/packages/graphql/src/schema-model/parser/annotations-parser/full-text-annotation.ts +++ b/packages/graphql/src/schema-model/parser/annotations-parser/full-text-annotation.ts @@ -18,14 +18,13 @@ */ import type { DirectiveNode } from "graphql"; import { fulltextDirective } from "../../../graphql/directives"; -import type { FullTextField } from "../../annotation/FullTextAnnotation"; -import { FullTextAnnotation } from "../../annotation/FullTextAnnotation"; +import { FulltextAnnotation, type FulltextField } from "../../annotation/FulltextAnnotation"; import { parseArguments } from "../parse-arguments"; -export function parseFullTextAnnotation(directive: DirectiveNode): FullTextAnnotation { - const { indexes } = parseArguments<{ indexes: FullTextField[] }>(fulltextDirective, directive); +export function parseFulltextAnnotation(directive: DirectiveNode): FulltextAnnotation { + const { indexes } = parseArguments<{ indexes: FulltextField[] }>(fulltextDirective, directive); - return new FullTextAnnotation({ + return new FulltextAnnotation({ indexes, }); } diff --git a/packages/graphql/src/schema/augment/fulltext.ts b/packages/graphql/src/schema/augment/fulltext.ts index eeb0af2213..9fab53a6db 100644 --- a/packages/graphql/src/schema/augment/fulltext.ts +++ b/packages/graphql/src/schema/augment/fulltext.ts @@ -19,73 +19,51 @@ import { GraphQLInt, GraphQLNonNull, GraphQLString } from "graphql"; import type { SchemaComposer } from "graphql-compose"; -import type { Node } from "../../classes"; +import Cypher from "@neo4j/cypher-builder"; import type { ConcreteEntityAdapter } from "../../schema-model/entity/model-adapters/ConcreteEntityAdapter"; +import type { FulltextContext } from "../../types"; import { - withFullTextInputType, - withFullTextResultType, - withFullTextSortInputType, - withFullTextWhereInputType, + withFulltextResultTypeConnection, + withFulltextSortInputType, + withFulltextWhereInputType, } from "../generation/fulltext-input"; import { fulltextResolver } from "../resolvers/query/fulltext"; -export function augmentFulltextSchema( - node: Node, - composer: SchemaComposer, - concreteEntityAdapter: ConcreteEntityAdapter -) { +export function augmentFulltextSchema({ + composer, + concreteEntityAdapter, +}: { + composer: SchemaComposer; + concreteEntityAdapter: ConcreteEntityAdapter; +}) { if (!concreteEntityAdapter.annotations.fulltext) { return; } - withFullTextInputType({ concreteEntityAdapter, composer }); - withFullTextWhereInputType({ composer, concreteEntityAdapter }); + withFulltextWhereInputType({ composer, concreteEntityAdapter }); - /** - * TODO [fulltext-deprecations] - * to move this over to the concreteEntityAdapter we need to check what the use of - * the queryType and scoreVariable properties are in FulltextContext - * and determine if we can remove them - */ concreteEntityAdapter.annotations.fulltext.indexes.forEach((index) => { - /** - * TODO [fulltext-deprecations] - * remove indexName assignment and undefined check once the name argument has been removed. - */ - const indexName = index.indexName || index.name; - if (indexName === undefined) { - throw new Error("The name of the fulltext index should be defined using the indexName argument."); - } + const fulltextContext: FulltextContext = { + index, + queryType: "query", + queryName: index.queryName, + scoreVariable: new Cypher.Variable(), + }; + + const fulltextArgs = { + phrase: new GraphQLNonNull(GraphQLString), + where: concreteEntityAdapter.operations.fulltextTypeNames.where, + sort: withFulltextSortInputType({ concreteEntityAdapter, composer }).NonNull.List, + first: GraphQLInt, + after: GraphQLString, + }; - let queryName = concreteEntityAdapter.operations.getFullTextIndexQueryFieldName(indexName); - if (index.queryName) { - queryName = index.queryName; - } - /** - * TODO [translation-layer-compatibility] - * temporary for compatibility with translation layer - */ - const nodeIndex = node.fulltextDirective!.indexes.find((i) => { - const iName = i.indexName || i.name; - return iName === indexName; - }); - if (!nodeIndex) { - throw new Error(`Could not find index ${indexName} on node ${node.name}`); - } composer.Query.addFields({ - [queryName]: { - type: withFullTextResultType({ composer, concreteEntityAdapter }).NonNull.List.NonNull, - description: - "Query a full-text index. This query returns the query score, but does not allow for aggregations. Use the `fulltext` argument under other queries for this functionality.", - resolve: fulltextResolver({ node, index: nodeIndex, entityAdapter: concreteEntityAdapter }), - args: { - phrase: new GraphQLNonNull(GraphQLString), - where: concreteEntityAdapter.operations.fulltextTypeNames.where, - sort: withFullTextSortInputType({ concreteEntityAdapter, composer }).NonNull.List, - limit: GraphQLInt, - offset: GraphQLInt, - }, + [index.queryName]: { + type: withFulltextResultTypeConnection({ composer, concreteEntityAdapter }).NonNull, + resolve: fulltextResolver({ fulltextContext, entityAdapter: concreteEntityAdapter }), + args: fulltextArgs, }, }); }); diff --git a/packages/graphql/src/schema/generation/fulltext-input.ts b/packages/graphql/src/schema/generation/fulltext-input.ts index f19e28632f..a8f292ff47 100644 --- a/packages/graphql/src/schema/generation/fulltext-input.ts +++ b/packages/graphql/src/schema/generation/fulltext-input.ts @@ -17,88 +17,15 @@ * limitations under the License. */ -import { GraphQLFloat, GraphQLNonNull, GraphQLString } from "graphql"; -import type { - InputTypeComposer, - InputTypeComposerFieldConfigMapDefinition, - ObjectTypeComposer, - SchemaComposer, -} from "graphql-compose"; +import { GraphQLFloat, GraphQLInt, GraphQLNonNull, GraphQLString } from "graphql"; +import type { InputTypeComposer, ObjectTypeComposer, SchemaComposer } from "graphql-compose"; import { SCORE_FIELD } from "../../constants"; import { SortDirection } from "../../graphql/enums/SortDirection"; import { FloatWhere } from "../../graphql/input-objects/FloatWhere"; +import { PageInfo } from "../../graphql/objects/PageInfo"; import type { ConcreteEntityAdapter } from "../../schema-model/entity/model-adapters/ConcreteEntityAdapter"; -export function withFullTextInputType({ - concreteEntityAdapter, - composer, -}: { - concreteEntityAdapter: ConcreteEntityAdapter; - composer: SchemaComposer; -}): InputTypeComposer | undefined { - const typeName = concreteEntityAdapter.operations.fullTextInputTypeName; - if (composer.has(typeName)) { - return composer.getITC(typeName); - } - const fields = makeFullTextInputFields({ concreteEntityAdapter, composer }); - const fulltextInputType = composer.createInputTC({ - name: typeName, - fields, - }); - return fulltextInputType; -} - -function makeFullTextInputFields({ - concreteEntityAdapter, - composer, -}: { - concreteEntityAdapter: ConcreteEntityAdapter; - composer: SchemaComposer; -}): InputTypeComposerFieldConfigMapDefinition { - const fields: InputTypeComposerFieldConfigMapDefinition = {}; - if (!concreteEntityAdapter.annotations.fulltext) { - throw new Error("Expected fulltext annotation"); - } - for (const index of concreteEntityAdapter.annotations.fulltext.indexes) { - const indexName = index.indexName || index.name; - if (indexName === undefined) { - throw new Error("The name of the fulltext index should be defined using the indexName argument."); - } - const fieldInput = withFullTextIndexInputType({ - concreteEntityAdapter, - indexName, - composer, - }); - if (fieldInput) { - fields[indexName] = fieldInput; - } - } - return fields; -} - -function withFullTextIndexInputType({ - composer, - concreteEntityAdapter, - indexName, -}: { - composer: SchemaComposer; - concreteEntityAdapter: ConcreteEntityAdapter; - indexName: string; -}): InputTypeComposer { - const typeName = concreteEntityAdapter.operations.getFullTextIndexInputTypeName(indexName); - if (composer.has(typeName)) { - return composer.getITC(typeName); - } - const indexInput = composer.createInputTC({ - name: typeName, - fields: { - phrase: new GraphQLNonNull(GraphQLString), - }, - }); - return indexInput; -} - -export function withFullTextWhereInputType({ +export function withFulltextWhereInputType({ composer, concreteEntityAdapter, }: { @@ -111,16 +38,16 @@ export function withFullTextWhereInputType({ } const whereInput = composer.createInputTC({ name: typeName, - description: `The input for filtering a fulltext query on an index of ${concreteEntityAdapter.name}`, + description: `The input for filtering a full-text query on an index of ${concreteEntityAdapter.name}`, fields: { [SCORE_FIELD]: FloatWhere.name, - [concreteEntityAdapter.singular]: concreteEntityAdapter.operations.whereInputTypeName, + ["node"]: concreteEntityAdapter.operations.whereInputTypeName, }, }); return whereInput; } -export function withFullTextSortInputType({ +export function withFulltextSortInputType({ composer, concreteEntityAdapter, }: { @@ -133,33 +60,44 @@ export function withFullTextSortInputType({ } const whereInput = composer.createInputTC({ name: typeName, - description: `The input for sorting a fulltext query on an index of ${concreteEntityAdapter.name}`, + description: `The input for sorting a Fulltext query on an index of ${concreteEntityAdapter.name}`, fields: { [SCORE_FIELD]: SortDirection.name, - [concreteEntityAdapter.singular]: concreteEntityAdapter.operations.sortInputTypeName, + node: concreteEntityAdapter.operations.sortInputTypeName, }, }); return whereInput; } -export function withFullTextResultType({ +export function withFulltextResultTypeConnection({ composer, concreteEntityAdapter, }: { composer: SchemaComposer; concreteEntityAdapter: ConcreteEntityAdapter; }): ObjectTypeComposer { - const typeName = concreteEntityAdapter.operations.fulltextTypeNames.result; + const typeName = concreteEntityAdapter.operations.fulltextTypeNames.connection; if (composer.has(typeName)) { return composer.getOTC(typeName); } - const whereInput = composer.createObjectTC({ - name: typeName, - description: `The result of a fulltext search on an index of ${concreteEntityAdapter.name}`, + + const edge = composer.createObjectTC({ + name: concreteEntityAdapter.operations.fulltextTypeNames.edge, fields: { + cursor: new GraphQLNonNull(GraphQLString), + node: `${concreteEntityAdapter.name}!`, [SCORE_FIELD]: new GraphQLNonNull(GraphQLFloat), - [concreteEntityAdapter.singular]: `${concreteEntityAdapter.name}!`, }, }); - return whereInput; + + const connection = composer.createObjectTC({ + name: typeName, + fields: { + totalCount: new GraphQLNonNull(GraphQLInt), + pageInfo: new GraphQLNonNull(PageInfo), + edges: edge.NonNull.List.NonNull, + }, + }); + + return connection; } diff --git a/packages/graphql/src/schema/get-nodes.ts b/packages/graphql/src/schema/get-nodes.ts index 8ce633caf7..14c0bc75cf 100644 --- a/packages/graphql/src/schema/get-nodes.ts +++ b/packages/graphql/src/schema/get-nodes.ts @@ -22,12 +22,11 @@ import type { NamedTypeNode } from "graphql"; import { Node } from "../classes"; import type { LimitDirective } from "../classes/LimitDirective"; import type { NodeDirective } from "../classes/NodeDirective"; -import type { FullText, Neo4jGraphQLCallbacks } from "../types"; +import type { Neo4jGraphQLCallbacks } from "../types"; import { asArray, haveSharedElement } from "../utils/utils"; import type { DefinitionNodes } from "./get-definition-nodes"; import getObjFieldMeta from "./get-obj-field-meta"; import parseNodeDirective from "./parse-node-directive"; -import parseFulltextDirective from "./parse/parse-fulltext-directive"; import { parseLimitDirective } from "./parse/parse-limit-directive"; import parsePluralDirective from "./parse/parse-plural-directive"; @@ -49,7 +48,7 @@ function getNodes( ): Nodes { let pointInTypeDefs = false; let cartesianPointInTypeDefs = false; - let floatWhereInTypeDefs = false; + const floatWhereInTypeDefs = false; const relationshipPropertyInterfaceNames = new Set(); const interfaceRelationshipNames = new Set(); @@ -86,7 +85,6 @@ function getNodes( const nodeDirectiveDefinition = (definition.directives || []).find((x) => x.name.value === "node"); const pluralDirectiveDefinition = (definition.directives || []).find((x) => x.name.value === "plural"); - const fulltextDirectiveDefinition = (definition.directives || []).find((x) => x.name.value === "fulltext"); const limitDirectiveDefinition = (definition.directives || []).find((x) => x.name.value === "limit"); const nodeInterfaces = [...(definition.interfaces || [])] as NamedTypeNode[]; @@ -111,16 +109,6 @@ function getNodes( customResolvers, }); - let fulltextDirective: FullText; - if (fulltextDirectiveDefinition) { - fulltextDirective = parseFulltextDirective({ - directive: fulltextDirectiveDefinition, - nodeFields, - definition, - }); - floatWhereInTypeDefs = true; - } - let limitDirective: LimitDirective | undefined; if (limitDirectiveDefinition) { limitDirective = parseLimitDirective({ @@ -159,8 +147,6 @@ function getNodes( ...nodeFields, // @ts-ignore we can be sure it's defined nodeDirective, - // @ts-ignore we can be sure it's defined - fulltextDirective, limitDirective, description: definition.description?.value, isGlobalNode: Boolean(globalIdField), diff --git a/packages/graphql/src/schema/make-augmented-schema.ts b/packages/graphql/src/schema/make-augmented-schema.ts index 6b6df692fe..8b5abf0a27 100644 --- a/packages/graphql/src/schema/make-augmented-schema.ts +++ b/packages/graphql/src/schema/make-augmented-schema.ts @@ -515,11 +515,7 @@ function generateObjectType({ features, composer, }); - /** - * TODO [translation-layer-compatibility] - * Need to migrate resolvers, which themselves rely on the translation layer being migrated to the new schema model - */ - augmentFulltextSchema(node, composer, concreteEntityAdapter); + augmentFulltextSchema({ composer, concreteEntityAdapter }); augmentVectorSchema({ composer, concreteEntityAdapter, features }); withUniqueWhereInputType({ concreteEntityAdapter, composer }); withCreateInputType({ entityAdapter: concreteEntityAdapter, userDefinedFieldDirectives, composer }); diff --git a/packages/graphql/src/schema/parse/parse-fulltext-directive.test.ts b/packages/graphql/src/schema/parse/parse-fulltext-directive.test.ts deleted file mode 100644 index 92933877ba..0000000000 --- a/packages/graphql/src/schema/parse/parse-fulltext-directive.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -/* - * 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 { DirectiveNode, ObjectTypeDefinitionNode } from "graphql"; -import gql from "graphql-tag"; -import getObjFieldMeta from "../get-obj-field-meta"; -import parseFulltextDirective from "./parse-fulltext-directive"; - -describe("parseFulltextDirective", () => { - test("should throw error when directive has duplicate name", () => { - const typeDefs = gql` - type Movie - @fulltext( - indexes: [{ indexName: "MyIndex", fields: ["title"] }, { indexName: "MyIndex", fields: ["title"] }] - ) { - title: String - description: String - } - `; - - const definition = typeDefs.definitions[0] as unknown as ObjectTypeDefinitionNode; - const directive = (definition.directives || [])[0] as DirectiveNode; - - const nodeFields = getObjFieldMeta({ - obj: definition, - enums: [], - interfaces: [], - scalars: [], - unions: [], - objects: [], - }); - - expect(() => - parseFulltextDirective({ - directive, - definition, - nodeFields, - }) - ).toThrow("Node 'Movie' @fulltext index contains duplicate name 'MyIndex'"); - }); - - test("should throw error when directive field is missing", () => { - const typeDefs = gql` - type Movie @fulltext(indexes: [{ indexName: "MyIndex", fields: ["title"] }]) @node { - description: String - imdbRating: Int - } - `; - - const definition = typeDefs.definitions[0] as unknown as ObjectTypeDefinitionNode; - const directive = (definition.directives || [])[0] as DirectiveNode; - - const nodeFields = getObjFieldMeta({ - obj: definition, - enums: [], - interfaces: [], - scalars: [], - unions: [], - objects: [], - }); - - expect(() => - parseFulltextDirective({ - directive, - definition, - nodeFields, - }) - ).toThrow( - "Node 'Movie' @fulltext index contains invalid index 'MyIndex' cannot use find String or ID field 'title'" - ); - }); - - test("should return valid Fulltext", () => { - const typeDefs = gql` - type Movie - @fulltext( - indexes: [ - { indexName: "MovieTitle", fields: ["title"] } - { indexName: "MovieDescription", fields: ["description"] } - ] - ) { - title: String - description: String - } - `; - - const definition = typeDefs.definitions[0] as unknown as ObjectTypeDefinitionNode; - const directive = (definition.directives || [])[0] as DirectiveNode; - - const nodeFields = getObjFieldMeta({ - obj: definition, - enums: [], - interfaces: [], - scalars: [], - unions: [], - objects: [], - }); - - const result = parseFulltextDirective({ - directive, - definition, - nodeFields, - }); - - expect(result).toEqual({ - indexes: [ - { indexName: "MovieTitle", fields: ["title"] }, - { indexName: "MovieDescription", fields: ["description"] }, - ], - }); - }); -}); diff --git a/packages/graphql/src/schema/parse/parse-fulltext-directive.ts b/packages/graphql/src/schema/parse/parse-fulltext-directive.ts deleted file mode 100644 index 317dc344c3..0000000000 --- a/packages/graphql/src/schema/parse/parse-fulltext-directive.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * 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 { ArgumentNode, DirectiveNode, ObjectTypeDefinitionNode } from "graphql"; -import type { FullText, FulltextContext } from "../../types"; -import type { ObjectFields } from "../get-obj-field-meta"; -import { parseValueNode } from "../../schema-model/parser/parse-value-node"; - -function parseFulltextDirective({ - directive, - nodeFields, - definition, -}: { - directive: DirectiveNode; - nodeFields: ObjectFields; - definition: ObjectTypeDefinitionNode; -}): FullText { - const indexesArg = directive.arguments?.find((arg) => arg.name.value === "indexes") as ArgumentNode; - const value = parseValueNode(indexesArg.value) as FulltextContext[]; - const compatibleFields = nodeFields.primitiveFields.filter( - (f) => ["String", "ID"].includes(f.typeMeta.name) && !f.typeMeta.array - ); - - value.forEach((index) => { - // TODO: remove indexName assignment and undefined check once the name argument has been removed. - const indexName = index.indexName || index.name; - if (indexName === undefined) { - throw new Error("The name of the fulltext index should be defined using the indexName argument."); - } - const names = value.filter((i) => indexName === i.indexName || indexName === i.name); - if (names.length > 1) { - throw new Error(`Node '${definition.name.value}' @fulltext index contains duplicate name '${indexName}'`); - } - - index.fields.forEach((field) => { - const foundField = compatibleFields.find((f) => f.fieldName === field); - if (!foundField) { - throw new Error( - `Node '${definition.name.value}' @fulltext index contains invalid index '${indexName}' cannot use find String or ID field '${field}'` - ); - } - }); - }); - - return { indexes: value }; -} - -export default parseFulltextDirective; diff --git a/packages/graphql/src/schema/resolvers/query/aggregate.ts b/packages/graphql/src/schema/resolvers/query/aggregate.ts index 23919637c7..3f0f4f4a0c 100644 --- a/packages/graphql/src/schema/resolvers/query/aggregate.ts +++ b/packages/graphql/src/schema/resolvers/query/aggregate.ts @@ -25,7 +25,6 @@ import type { Neo4jGraphQLTranslationContext } from "../../../types/neo4j-graphq import { execute } from "../../../utils"; import getNeo4jResolveTree from "../../../utils/get-neo4j-resolve-tree"; import type { Neo4jGraphQLComposedContext } from "../composition/wrap-query-and-mutation"; -import { isConcreteEntity } from "../../../translate/queryAST/utils/is-concrete-entity"; export function aggregateResolver({ entityAdapter, @@ -58,15 +57,6 @@ export function aggregateResolver({ resolve, args: { where: entityAdapter.operations.whereInputTypeName, - ...(isConcreteEntity(entityAdapter) && entityAdapter.annotations.fulltext - ? { - fulltext: { - type: entityAdapter.operations.fullTextInputTypeName, - description: - "Query a full-text index. Allows for the aggregation of results, but does not return the query score. Use the root full-text query fields if you require the score.", - }, - } - : {}), }, }; } diff --git a/packages/graphql/src/schema/resolvers/query/fulltext.ts b/packages/graphql/src/schema/resolvers/query/fulltext.ts index 6184eec08c..28aefb8c11 100644 --- a/packages/graphql/src/schema/resolvers/query/fulltext.ts +++ b/packages/graphql/src/schema/resolvers/query/fulltext.ts @@ -17,25 +17,23 @@ * limitations under the License. */ -import Cypher from "@neo4j/cypher-builder"; -import type { GraphQLFieldResolver, GraphQLResolveInfo } from "graphql"; -import type { Node } from "../../../classes"; +import type { GraphQLFieldResolver, GraphQLResolveInfo, SelectionSetNode } from "graphql"; import type { ConcreteEntityAdapter } from "../../../schema-model/entity/model-adapters/ConcreteEntityAdapter"; import type { InterfaceEntityAdapter } from "../../../schema-model/entity/model-adapters/InterfaceEntityAdapter"; import { translateRead } from "../../../translate"; import type { FulltextContext } from "../../../types"; -import type { Neo4jGraphQLTranslationContext } from "../../../types/neo4j-graphql-translation-context"; import { execute } from "../../../utils"; import getNeo4jResolveTree from "../../../utils/get-neo4j-resolve-tree"; +import { isNeoInt } from "../../../utils/utils"; +import { createConnectionWithEdgeProperties } from "../../pagination"; import type { Neo4jGraphQLComposedContext } from "../composition/wrap-query-and-mutation"; +import { emptyConnection } from "./empty-connection"; export function fulltextResolver({ - node, - index, + fulltextContext, entityAdapter, }: { - node: Node; - index: FulltextContext; + fulltextContext: FulltextContext; entityAdapter: ConcreteEntityAdapter | InterfaceEntityAdapter; }): GraphQLFieldResolver { return async function resolve( @@ -44,8 +42,7 @@ export function fulltextResolver({ context: Neo4jGraphQLComposedContext, info: GraphQLResolveInfo ) { - context.fulltext = index; - context.fulltext.scoreVariable = new Cypher.Variable(); + context.fulltext = fulltextContext; const resolveTree = getNeo4jResolveTree(info, { args }); resolveTree.args.options = { @@ -54,12 +51,10 @@ export function fulltextResolver({ offset: resolveTree.args.offset, }; - (context as Neo4jGraphQLTranslationContext).resolveTree = resolveTree; - const { cypher, params } = translateRead({ - context: context as Neo4jGraphQLTranslationContext, + context: { ...context, resolveTree }, entityAdapter, - varName: node.singular, + varName: "this", }); const executeResult = await execute({ cypher, @@ -68,6 +63,24 @@ export function fulltextResolver({ context, info, }); - return executeResult.records; + + if (!executeResult.records[0]) { + return { [entityAdapter.operations.rootTypeFieldNames.connection]: emptyConnection }; + } + + const record = executeResult.records[0].this; + const totalCount = isNeoInt(record.totalCount) ? record.totalCount.toNumber() : record.totalCount; + const connection = createConnectionWithEdgeProperties({ + selectionSet: resolveTree as unknown as SelectionSetNode, + source: { edges: record.edges }, + args: { first: args.first, after: args.after }, + totalCount, + }); + + return { + totalCount, + edges: connection.edges, + pageInfo: connection.pageInfo, + }; }; } diff --git a/packages/graphql/src/schema/resolvers/query/read.ts b/packages/graphql/src/schema/resolvers/query/read.ts index cca3e440dd..50358219d6 100644 --- a/packages/graphql/src/schema/resolvers/query/read.ts +++ b/packages/graphql/src/schema/resolvers/query/read.ts @@ -68,15 +68,6 @@ export function findResolver({ } const extraArgs = {}; - if (isConcreteEntity(entityAdapter)) { - if (entityAdapter.annotations.fulltext) { - extraArgs["fulltext"] = { - type: `${entityAdapter.name}Fulltext`, - description: - "Query a full-text index. Allows for the aggregation of results, but does not return the query score. Use the root full-text query fields if you require the score.", - }; - } - } const whereArgumentType = entityAdapter.operations.whereInputTypeName; const args = { diff --git a/packages/graphql/src/schema/resolvers/query/root-connection.ts b/packages/graphql/src/schema/resolvers/query/root-connection.ts index 5313d0ee7e..10dffb2b70 100644 --- a/packages/graphql/src/schema/resolvers/query/root-connection.ts +++ b/packages/graphql/src/schema/resolvers/query/root-connection.ts @@ -117,15 +117,6 @@ export function rootConnectionResolver({ after: GraphQLString, where: entityAdapter.operations.whereInputTypeName, ...(sortArg ? { sort: sortArg.NonNull.List } : {}), - ...(entityAdapter.annotations.fulltext - ? { - fulltext: { - type: entityAdapter.operations.fullTextInputTypeName, - description: - "Query a full-text index. Allows for the aggregation of results, but does not return the query score. Use the root full-text query fields if you require the score.", - }, - } - : {}), }, }; } diff --git a/packages/graphql/src/schema/validation/custom-rules/directives/fulltext.ts b/packages/graphql/src/schema/validation/custom-rules/directives/fulltext.ts index dd008464d3..b347127d51 100644 --- a/packages/graphql/src/schema/validation/custom-rules/directives/fulltext.ts +++ b/packages/graphql/src/schema/validation/custom-rules/directives/fulltext.ts @@ -18,8 +18,8 @@ */ import type { DirectiveNode, FieldDefinitionNode } from "graphql"; import { Kind } from "graphql"; +import type { FulltextField } from "../../../../schema-model/annotation/FulltextAnnotation"; import { parseValueNode } from "../../../../schema-model/parser/parse-value-node"; -import type { FulltextContext } from "../../../../types"; import { DocumentValidationError } from "../utils/document-validation-error"; import type { ObjectOrInterfaceWithExtensions } from "../utils/path-parser"; @@ -39,7 +39,7 @@ export function verifyFulltext({ // delegate to DirectiveArgumentOfCorrectType rule return; } - const indexesValue = parseValueNode(indexesArg.value) as FulltextContext[]; + const indexesValue = parseValueNode(indexesArg.value) as FulltextField[]; const compatibleFields = traversedDef.fields?.filter((f) => { if (f.type.kind === Kind.NON_NULL_TYPE) { const innerType = f.type.type; @@ -53,12 +53,22 @@ export function verifyFulltext({ return false; }); indexesValue.forEach((index) => { - const indexName = index.indexName || index.name; - const names = indexesValue.filter((i) => indexName === (i.indexName || i.name)); - if (names.length > 1) { - throw new DocumentValidationError(`@fulltext.indexes invalid value for: ${indexName}. Duplicate name.`, [ - "indexes", - ]); + const indexName = index.indexName; + const indexNames = indexesValue.filter((i) => indexName === i.indexName); + if (indexNames.length > 1) { + throw new DocumentValidationError( + `@fulltext.indexes invalid value for: ${indexName}. Duplicate index name.`, + ["indexes"] + ); + } + + const queryName = index.queryName; + const queryNames = indexesValue.filter((i) => queryName === i.queryName); + if (queryNames.length > 1) { + throw new DocumentValidationError( + `@fulltext.indexes invalid value for: ${queryName}. Duplicate query name.`, + ["indexes"] + ); } (index.fields || []).forEach((field) => { diff --git a/packages/graphql/src/schema/validation/validate-document.test.ts b/packages/graphql/src/schema/validation/validate-document.test.ts index fda0a4ce2e..66e7fd6209 100644 --- a/packages/graphql/src/schema/validation/validate-document.test.ts +++ b/packages/graphql/src/schema/validation/validate-document.test.ts @@ -545,14 +545,16 @@ describe("validation 2.0", () => { name: String } `; - // TODO: is "[FullTextInput]!" type exposed to the user? + // TODO: is "[FulltextInput]!" type exposed to the user? expect(() => validateDocument({ document: doc, features: {}, additionalDefinitions })).toThrow( - 'Directive "@fulltext" argument "indexes" of type "[FullTextInput]!" is required, but it was not provided.' + 'Directive "@fulltext" argument "indexes" of type "[FulltextInput]!" is required, but it was not provided.' ); }); test("@fulltext ok", () => { const doc = gql` - type User @fulltext(indexes: [{ fields: ["name"] }]) @node { + type User + @fulltext(indexes: [{ indexName: "UserIndex", queryName: "usersByName", fields: ["name"] }]) + @node { name: String } `; @@ -790,7 +792,7 @@ describe("validation 2.0", () => { describe("Directive Argument Type", () => { test("@fulltext.indexes property required", () => { const doc = gql` - type User @fulltext(indexes: [{ name: "something" }]) @node { + type User @fulltext(indexes: [{ indexName: "something", queryName: "something" }]) @node { name: String } `; @@ -811,7 +813,7 @@ describe("validation 2.0", () => { type User @node { name: String } - extend type User @fulltext(indexes: [{ name: "something" }]) + extend type User @fulltext(indexes: [{ indexName: "something", queryName: "something" }]) `; const executeValidate = () => validateDocument({ document: doc, features: {}, additionalDefinitions }); @@ -2534,7 +2536,12 @@ describe("validation 2.0", () => { test("@fulltext duplicate index names", () => { const doc = gql` type User - @fulltext(indexes: [{ indexName: "a", fields: ["name"] }, { indexName: "a", fields: ["id"] }]) { + @fulltext( + indexes: [ + { indexName: "a", queryName: "a", fields: ["name"] } + { indexName: "a", queryName: "b", fields: ["id"] } + ] + ) { name: String id: ID } @@ -2545,7 +2552,10 @@ describe("validation 2.0", () => { expect(errors).toHaveLength(1); expect(errors[0]).not.toBeInstanceOf(NoErrorThrownError); - expect(errors[0]).toHaveProperty("message", "@fulltext.indexes invalid value for: a. Duplicate name."); + expect(errors[0]).toHaveProperty( + "message", + "@fulltext.indexes invalid value for: a. Duplicate index name." + ); expect(errors[0]).toHaveProperty("path", ["User", "@fulltext", "indexes"]); }); @@ -2556,7 +2566,65 @@ describe("validation 2.0", () => { id: ID } extend type User - @fulltext(indexes: [{ indexName: "a", fields: ["name"] }, { indexName: "a", fields: ["id"] }]) + @fulltext( + indexes: [ + { indexName: "a", queryName: "a", fields: ["name"] } + { indexName: "a", queryName: "b", fields: ["id"] } + ] + ) + `; + + const executeValidate = () => validateDocument({ document: doc, features: {}, additionalDefinitions }); + const errors = getError(executeValidate); + + expect(errors).toHaveLength(1); + expect(errors[0]).not.toBeInstanceOf(NoErrorThrownError); + expect(errors[0]).toHaveProperty( + "message", + "@fulltext.indexes invalid value for: a. Duplicate index name." + ); + expect(errors[0]).toHaveProperty("path", ["User", "@fulltext", "indexes"]); + }); + + test("@fulltext duplicate query names", () => { + const doc = gql` + type User + @fulltext( + indexes: [ + { indexName: "a", queryName: "a", fields: ["name"] } + { indexName: "b", queryName: "a", fields: ["id"] } + ] + ) { + name: String + id: ID + } + `; + + const executeValidate = () => validateDocument({ document: doc, features: {}, additionalDefinitions }); + const errors = getError(executeValidate); + + expect(errors).toHaveLength(1); + expect(errors[0]).not.toBeInstanceOf(NoErrorThrownError); + expect(errors[0]).toHaveProperty( + "message", + "@fulltext.indexes invalid value for: a. Duplicate query name." + ); + expect(errors[0]).toHaveProperty("path", ["User", "@fulltext", "indexes"]); + }); + + test("@fulltext duplicate query names extension", () => { + const doc = gql` + type User @node { + name: String + id: ID + } + extend type User + @fulltext( + indexes: [ + { indexName: "a", queryName: "a", fields: ["name"] } + { indexName: "b", queryName: "a", fields: ["id"] } + ] + ) `; const executeValidate = () => validateDocument({ document: doc, features: {}, additionalDefinitions }); @@ -2564,13 +2632,16 @@ describe("validation 2.0", () => { expect(errors).toHaveLength(1); expect(errors[0]).not.toBeInstanceOf(NoErrorThrownError); - expect(errors[0]).toHaveProperty("message", "@fulltext.indexes invalid value for: a. Duplicate name."); + expect(errors[0]).toHaveProperty( + "message", + "@fulltext.indexes invalid value for: a. Duplicate query name." + ); expect(errors[0]).toHaveProperty("path", ["User", "@fulltext", "indexes"]); }); test("@fulltext index on type not String or ID", () => { const doc = gql` - type User @fulltext(indexes: [{ indexName: "a", fields: ["age"] }]) @node { + type User @fulltext(indexes: [{ indexName: "a", queryName: "a", fields: ["age"] }]) @node { age: Int } `; @@ -2590,7 +2661,12 @@ describe("validation 2.0", () => { test("@fulltext correct usage", () => { const doc = gql` type User - @fulltext(indexes: [{ indexName: "a", fields: ["name"] }, { indexName: "b", fields: ["id"] }]) { + @fulltext( + indexes: [ + { indexName: "a", queryName: "a", fields: ["name"] } + { indexName: "b", queryName: "b", fields: ["id"] } + ] + ) { id: ID name: String } @@ -5894,7 +5970,7 @@ describe("validation 2.0", () => { `; expect(() => validateDocument({ document: doc, features: undefined, additionalDefinitions })).toThrow( - 'Directive "@fulltext" argument "indexes" of type "[FullTextInput]!" is required, but it was not provided.' + 'Directive "@fulltext" argument "indexes" of type "[FulltextInput]!" is required, but it was not provided.' ); }); diff --git a/packages/graphql/src/translate/queryAST/ast/operations/FulltextOperation.ts b/packages/graphql/src/translate/queryAST/ast/operations/FulltextOperation.ts index 14b04610c9..bdd8f4fef4 100644 --- a/packages/graphql/src/translate/queryAST/ast/operations/FulltextOperation.ts +++ b/packages/graphql/src/translate/queryAST/ast/operations/FulltextOperation.ts @@ -18,6 +18,7 @@ */ import Cypher from "@neo4j/cypher-builder"; +import type { FulltextField } from "../../../../schema-model/annotation/FulltextAnnotation"; import type { ConcreteEntityAdapter } from "../../../../schema-model/entity/model-adapters/ConcreteEntityAdapter"; import type { RelationshipAdapter } from "../../../../schema-model/relationship/model-adapters/RelationshipAdapter"; import { filterTruthy } from "../../../../utils/utils"; @@ -25,16 +26,16 @@ import type { QueryASTContext } from "../QueryASTContext"; import type { QueryASTNode } from "../QueryASTNode"; import type { ScoreField } from "../fields/ScoreField"; import type { EntitySelection } from "../selection/EntitySelection"; -import { ReadOperation } from "./ReadOperation"; -import type { OperationTranspileResult } from "./operations"; +import { ScoreSort } from "../sort/ScoreSort"; +import { ConnectionReadOperation } from "./ConnectionReadOperation"; export type FulltextOptions = { - index: string; + index: FulltextField; phrase: string; score: Cypher.Variable; }; -export class FulltextOperation extends ReadOperation { +export class FulltextOperation extends ConnectionReadOperation { private scoreField: ScoreField | undefined; constructor({ @@ -57,37 +58,62 @@ export class FulltextOperation extends ReadOperation { this.scoreField = scoreField; } - public transpile(context: QueryASTContext): OperationTranspileResult { - const { clauses, projectionExpr } = super.transpile(context); - - const extraProjectionColumns: Array<[Cypher.Expr, Cypher.Variable]> = []; + public getChildren(): QueryASTNode[] { + return filterTruthy([...super.getChildren(), this.scoreField]); + } - if (this.scoreField) { - const scoreProjection = this.scoreField.getProjectionField(); + protected createProjectionMapForEdge(context: QueryASTContext): Cypher.Map { + const edgeProjectionMap = new Cypher.Map(); - extraProjectionColumns.push([scoreProjection.score, new Cypher.NamedVariable("score")]); + edgeProjectionMap.set("node", this.createProjectionMapForNode(context)); + if (this.scoreField && context.neo4jGraphQLContext.fulltext) { + edgeProjectionMap.set("score", context.neo4jGraphQLContext.fulltext.scoreVariable); } - - return { - clauses, - projectionExpr, - extraProjectionColumns, - }; + return edgeProjectionMap; } - public getChildren(): QueryASTNode[] { - return filterTruthy([...super.getChildren(), this.scoreField]); + protected getUnwindClause( + context: QueryASTContext, + edgeVar: Cypher.Variable, + edgesVar: Cypher.Variable + ): Cypher.With { + if ((this.scoreField || this.hasScoreSort()) && context.neo4jGraphQLContext.fulltext) { + // No relationship, so we directly unwind node and score + return new Cypher.Unwind([edgesVar, edgeVar]).with( + [edgeVar.property("node"), context.target], + [edgeVar.property("score"), context.neo4jGraphQLContext.fulltext.scoreVariable] + ); + } else { + return super.getUnwindClause(context, edgeVar, edgesVar); + } } - protected getReturnStatement(context: QueryASTContext, returnVariable: Cypher.Variable): Cypher.Return { - const returnClause = super.getReturnStatement(context, returnVariable); + protected getWithCollectEdgesAndTotalCount( + nestedContext: QueryASTContext, + edgesVar: Cypher.Variable, + totalCount: Cypher.Variable + ): Cypher.With { + if ((this.scoreField || this.hasScoreSort()) && nestedContext.neo4jGraphQLContext.fulltext) { + const nodeAndRelationshipMap = new Cypher.Map({ + node: nestedContext.target, + }); - if (this.scoreField) { - const scoreProjection = this.scoreField.getProjectionField(); + if (nestedContext.relationship) { + nodeAndRelationshipMap.set("relationship", nestedContext.relationship); + } - returnClause.addColumns([scoreProjection.score, "score"]); + nodeAndRelationshipMap.set("score", nestedContext.neo4jGraphQLContext.fulltext.scoreVariable); + + return new Cypher.With([Cypher.collect(nodeAndRelationshipMap), edgesVar]).with(edgesVar, [ + Cypher.size(edgesVar), + totalCount, + ]); + } else { + return super.getWithCollectEdgesAndTotalCount(nestedContext, edgesVar, totalCount); } + } - return returnClause; + private hasScoreSort(): boolean { + return this.sortFields.some(({ node }) => node.some((sort) => sort instanceof ScoreSort)); } } diff --git a/packages/graphql/src/translate/queryAST/ast/selection/FulltextSelection.ts b/packages/graphql/src/translate/queryAST/ast/selection/FulltextSelection.ts index ac604c6954..111bdc0917 100644 --- a/packages/graphql/src/translate/queryAST/ast/selection/FulltextSelection.ts +++ b/packages/graphql/src/translate/queryAST/ast/selection/FulltextSelection.ts @@ -26,32 +26,31 @@ import { EntitySelection, type SelectionClause } from "./EntitySelection"; export class FulltextSelection extends EntitySelection { private target: ConcreteEntityAdapter; - private fulltext: FulltextOptions; + private fulltextOptions: FulltextOptions; private scoreVariable: Cypher.Variable; constructor({ target, - fulltext, + fulltextOptions, scoreVariable, }: { target: ConcreteEntityAdapter; - fulltext: FulltextOptions; + fulltextOptions: FulltextOptions; scoreVariable: Cypher.Variable; }) { super(); this.target = target; - this.fulltext = fulltext; + this.fulltextOptions = fulltextOptions; this.scoreVariable = scoreVariable; } - public apply(context: QueryASTContext): { nestedContext: QueryASTContext; selection: SelectionClause; } { const node = new Cypher.Node(); - const phraseParam = new Cypher.Param(this.fulltext.phrase); - const indexName = new Cypher.Literal(this.fulltext.index); + const phraseParam = new Cypher.Param(this.fulltextOptions.phrase); + const indexName = new Cypher.Literal(this.fulltextOptions.index.indexName); const fulltextClause: Cypher.Yield = Cypher.db.index.fulltext .queryNodes(indexName, phraseParam) diff --git a/packages/graphql/src/translate/queryAST/factory/OperationFactory.ts b/packages/graphql/src/translate/queryAST/factory/OperationFactory.ts index d729628e2b..5320c3562f 100644 --- a/packages/graphql/src/translate/queryAST/factory/OperationFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/OperationFactory.ts @@ -109,30 +109,9 @@ export class OperationsFactory { reference?: any; resolveAsUnwind?: boolean; }): Operation { - if ( - entity && - isConcreteEntity(entity) && - Boolean(entity.annotations.fulltext) && - context.fulltext && - context.resolveTree.args.phrase - ) { - // Handles the new FullText operation as moviesFullText(phrase: "The Matrix") {...} - const indexName = context.fulltext.indexName ?? context.fulltext.name; - if (indexName === undefined) { - throw new Error("The name of the fulltext index should be defined using the indexName argument."); - } - assertIsConcreteEntity(entity); - return this.fulltextFactory.createFulltextOperation(entity, resolveTree, context); - } - const operationMatch = parseTopLevelOperationField(resolveTree.name, context, entity); switch (operationMatch) { case "READ": { - // handle the deprecated way to do FullText search - if (context.resolveTree.args.fulltext || context.resolveTree.args.phrase) { - assertIsConcreteEntity(entity); - return this.fulltextFactory.createDeprecatedFulltextOperation(entity, resolveTree, context); - } if (!entity) { throw new Error("Entity is required for top level read operations"); } @@ -144,6 +123,16 @@ export class OperationsFactory { reference, }); } + case "FULLTEXT": { + if (!entity) { + throw new Error("Entity is required for top level connection read operations"); + } + if (!isConcreteEntity(entity)) { + throw new Error("Fulltext operations are only supported on concrete entities"); + } + + return this.fulltextFactory.createFulltextOperation(entity, resolveTree, context); + } case "VECTOR": { if (!entity) { throw new Error("Entity is required for top level connection read operations"); diff --git a/packages/graphql/src/translate/queryAST/factory/Operations/FulltextFactory.ts b/packages/graphql/src/translate/queryAST/factory/Operations/FulltextFactory.ts index f6c0658f13..d06cc23134 100644 --- a/packages/graphql/src/translate/queryAST/factory/Operations/FulltextFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/Operations/FulltextFactory.ts @@ -17,19 +17,18 @@ * limitations under the License. */ -import Cypher from "@neo4j/cypher-builder"; import type { ResolveTree } from "graphql-parse-resolve-info"; import type { ConcreteEntityAdapter } from "../../../../schema-model/entity/model-adapters/ConcreteEntityAdapter"; import type { Neo4jGraphQLTranslationContext } from "../../../../types/neo4j-graphql-translation-context"; -import { asArray } from "../../../../utils/utils"; import { checkEntityAuthentication } from "../../../authorization/check-authentication"; import { ScoreField } from "../../ast/fields/ScoreField"; import { ScoreFilter } from "../../ast/filters/property-filters/ScoreFilter"; import type { FulltextOptions } from "../../ast/operations/FulltextOperation"; import { FulltextOperation } from "../../ast/operations/FulltextOperation"; import { FulltextSelection } from "../../ast/selection/FulltextSelection"; -import { raiseOnMixedPagination } from "../../utils/raise-on-mixed-pagination"; import type { QueryASTFactory } from "../QueryASTFactory"; +import { findFieldsByNameInFieldsByTypeNameField } from "../parsers/find-fields-by-name-in-fields-by-type-name-field"; +import { getFieldsByTypeName } from "../parsers/get-fields-by-type-name"; export class FulltextFactory { private queryASTFactory: QueryASTFactory; @@ -37,23 +36,14 @@ export class FulltextFactory { constructor(queryASTFactory: QueryASTFactory) { this.queryASTFactory = queryASTFactory; } - /** - * @deprecated This method is deprecated an it will be removed when the deprecate fulltext operation will be removed. - * The is the factory method that parse the deprecated syntax as movies(fulltext: { phrase: "The Matrix" }) {...} - * To parse the new syntax movieFullText(phrase: "The Matrix") {...} use the method createFulltextOperation - * - **/ - public createDeprecatedFulltextOperation( + + public createFulltextOperation( entity: ConcreteEntityAdapter, resolveTree: ResolveTree, context: Neo4jGraphQLTranslationContext ): FulltextOperation { - const resolveTreeWhere: Record = this.queryASTFactory.operationsFactory.getWhereArgs(resolveTree); - - const fieldsByTypeName = resolveTree.fieldsByTypeName; - const fullTextOptions = this.getFulltextOptions(context); - let scoreField: ScoreField | undefined; - let scoreFilter: ScoreFilter | undefined; + const resolveTreeWhere: Record = + this.queryASTFactory.operationsFactory.getWhereArgs(resolveTree) ?? {}; checkEntityAuthentication({ entity: entity.entity, @@ -61,165 +51,71 @@ export class FulltextFactory { context, }); - const selection = new FulltextSelection({ - target: entity, - fulltext: fullTextOptions, - scoreVariable: fullTextOptions.score, - }); - const operation = new FulltextOperation({ - target: entity, - scoreField, - selection, - }); - - if (scoreFilter) { - operation.addFilters(scoreFilter); - } - - this.queryASTFactory.operationsFactory.hydrateOperation({ - operation, - entity, - fieldsByTypeName: fieldsByTypeName, - context, - whereArgs: resolveTreeWhere, - }); - - // Override sort to support score - // SOFT_DEPRECATION: OPTIONS-ARGUMENT - const optionsArg: Record = (resolveTree.args.options ?? {}) as Record; - const sortArg = resolveTree.args.sort ?? optionsArg.sort; - const limitArg = resolveTree.args.limit ?? optionsArg.limit; - const offsetArg = resolveTree.args.offset ?? optionsArg.offset; - raiseOnMixedPagination({ - optionsArg, - sort: resolveTree.args.sort, - limit: resolveTree.args.limit, - offset: resolveTree.args.offset, - }); - const paginationOptions = this.queryASTFactory.operationsFactory.getOptions({ - entity, - limitArg, - offsetArg, - sortArg, - }); - - if (paginationOptions) { - const sort = this.queryASTFactory.sortAndPaginationFactory.createSortFields( - paginationOptions, - entity, - context, - fullTextOptions.score - ); - operation.addSort(...sort); - - const pagination = this.queryASTFactory.sortAndPaginationFactory.createPagination(paginationOptions); - if (pagination) { - operation.addPagination(pagination); - } - } - - return operation; - } - - public createFulltextOperation( - entity: ConcreteEntityAdapter, - resolveTree: ResolveTree, - context: Neo4jGraphQLTranslationContext - ): FulltextOperation { - const fullTextDeprecateOperationFields = - resolveTree.fieldsByTypeName[entity.operations.fulltextTypeNames.result]; - - if (!fullTextDeprecateOperationFields) { - throw new Error("Transpile error: operation not found"); - } - const resolveTreeWhere: Record = this.queryASTFactory.operationsFactory.getWhereArgs(resolveTree); - - const fullTextOptions = this.getFulltextOptions(context); let scoreField: ScoreField | undefined; - let scoreFilter: ScoreFilter | undefined; - - const scoreWhere = resolveTreeWhere.score; - const targetTypeWhere = resolveTreeWhere[entity.singular] ?? {}; + const fulltextConnectionFields = resolveTree.fieldsByTypeName[entity.operations.fulltextTypeNames.connection]; - const scoreRawField = fullTextDeprecateOperationFields.score; + if (!fulltextConnectionFields) { + throw new Error("Fulltext result field not found"); + } - const nestedResolveTree: Record = fullTextDeprecateOperationFields[entity.singular] ?? {}; + const filteredResolveTreeEdges = findFieldsByNameInFieldsByTypeNameField(fulltextConnectionFields, "edges"); + const edgeFields = getFieldsByTypeName(filteredResolveTreeEdges, entity.operations.fulltextTypeNames.edge); + const scoreFields = findFieldsByNameInFieldsByTypeNameField(edgeFields, "score"); - if (scoreRawField) { + // We only care about the first score field + if (scoreFields.length > 0 && scoreFields[0] && context.fulltext) { scoreField = new ScoreField({ - alias: scoreRawField.alias, - score: fullTextOptions.score, - }); - } - if (scoreWhere) { - scoreFilter = new ScoreFilter({ - scoreVariable: fullTextOptions.score, - min: scoreWhere.min, - max: scoreWhere.max, + alias: scoreFields[0].alias, + score: context.fulltext.scoreVariable, }); } - checkEntityAuthentication({ - entity: entity.entity, - targetOperations: ["READ"], - context, - }); - - const selection = new FulltextSelection({ - target: entity, - fulltext: fullTextOptions, - scoreVariable: fullTextOptions.score, - }); const operation = new FulltextOperation({ target: entity, scoreField, - selection, + selection: this.getFulltextSelection(entity, context), }); - if (scoreFilter) { - operation.addFilters(scoreFilter); - } - const fieldsByTypeName = nestedResolveTree.fieldsByTypeName ?? {}; - this.queryASTFactory.operationsFactory.hydrateOperation({ + const concreteEdgeFields = getFieldsByTypeName( + filteredResolveTreeEdges, + entity.operations.fulltextTypeNames.edge + ); + + this.addFulltextScoreFilter({ operation, - entity, - fieldsByTypeName, context, - whereArgs: targetTypeWhere, + whereArgs: resolveTreeWhere, }); - // SOFT_DEPRECATION: OPTIONS-ARGUMENT - const optionsArg: Record = (resolveTree.args.options ?? {}) as Record; - // Override sort to support score and other fields as: { score: "DESC", movie: { title: DESC }} - const sortArg = asArray(resolveTree.args.sort ?? optionsArg.sort).map( - (field) => field[entity.singular] ?? field - ); - const limitArg = resolveTree.args.limit ?? optionsArg.limit; - const offsetArg = resolveTree.args.offset ?? optionsArg.offset; - - const paginationOptions = this.queryASTFactory.operationsFactory.getOptions({ - entity, - limitArg, - offsetArg, - sortArg, + this.queryASTFactory.operationsFactory.hydrateConnectionOperation({ + target: entity, + resolveTree: resolveTree, + context, + operation, + whereArgs: resolveTreeWhere, + resolveTreeEdgeFields: concreteEdgeFields, }); - if (paginationOptions) { - const sort = this.queryASTFactory.sortAndPaginationFactory.createSortFields( - paginationOptions, - entity, - context, - fullTextOptions.score - ); - operation.addSort(...sort); + return operation; + } - const pagination = this.queryASTFactory.sortAndPaginationFactory.createPagination(paginationOptions); - if (pagination) { - operation.addPagination(pagination); - } + private addFulltextScoreFilter({ + operation, + whereArgs, + context, + }: { + operation: FulltextOperation; + whereArgs: Record; + context: Neo4jGraphQLTranslationContext; + }): void { + if (whereArgs.score && context?.fulltext) { + const scoreFilter = new ScoreFilter({ + scoreVariable: context.fulltext.scoreVariable, + min: whereArgs.score.min, + max: whereArgs.score.max, + }); + operation.addFilters(scoreFilter); } - - return operation; } public getFulltextSelection( @@ -229,38 +125,25 @@ export class FulltextFactory { const fulltextOptions = this.getFulltextOptions(context); return new FulltextSelection({ target: entity, - fulltext: fulltextOptions, + fulltextOptions, scoreVariable: fulltextOptions.score, }); } private getFulltextOptions(context: Neo4jGraphQLTranslationContext): FulltextOptions { - if (context.fulltext) { - const indexName = context.fulltext.indexName ?? context.fulltext.name; - if (indexName === undefined) { - throw new Error("The name of the fulltext index should be defined using the indexName argument."); - } - const phrase = context.resolveTree.args.phrase; - if (!phrase || typeof phrase !== "string") { - throw new Error("Invalid phrase"); - } - - return { - index: indexName, - phrase, - score: context.fulltext.scoreVariable, - }; + if (!context.fulltext) { + throw new Error("Fulltext context is missing"); } - const entries = Object.entries(context.resolveTree.args.fulltext || {}); - if (entries.length > 1) { - throw new Error("Can only call one search at any given time"); + const phrase = context.resolveTree.args.phrase; + if (!phrase || typeof phrase !== "string") { + throw new Error("Invalid phrase"); } - const [indexName, indexInput] = entries[0] as [string, { phrase: string }]; + return { - index: indexName, - phrase: indexInput.phrase, - score: new Cypher.Variable(), + index: context.fulltext.index, + phrase, + score: context.fulltext.scoreVariable, }; } } diff --git a/packages/graphql/src/translate/queryAST/factory/SortAndPaginationFactory.ts b/packages/graphql/src/translate/queryAST/factory/SortAndPaginationFactory.ts index 490659e159..3d5705f277 100644 --- a/packages/graphql/src/translate/queryAST/factory/SortAndPaginationFactory.ts +++ b/packages/graphql/src/translate/queryAST/factory/SortAndPaginationFactory.ts @@ -17,7 +17,6 @@ * limitations under the License. */ -import type Cypher from "@neo4j/cypher-builder"; import { SCORE_FIELD } from "../../../constants"; import type { EntityAdapter } from "../../../schema-model/entity/EntityAdapter"; import { RelationshipAdapter } from "../../../schema-model/relationship/model-adapters/RelationshipAdapter"; @@ -43,12 +42,11 @@ export class SortAndPaginationFactory { public createSortFields( options: GraphQLOptionsArg, entity: EntityAdapter | RelationshipAdapter, - context: Neo4jGraphQLTranslationContext, - scoreVariable?: Cypher.Variable + context: Neo4jGraphQLTranslationContext ): Sort[] { // SOFT_DEPRECATION: OPTIONS-ARGUMENT return asArray(options.sort).flatMap((s) => { - return this.createPropertySort({ optionArg: s, entity, context, scoreVariable }); + return this.createPropertySort({ optionArg: s, entity, context }); }); } @@ -80,12 +78,22 @@ export class SortAndPaginationFactory { context, }); - if (options[SCORE_FIELD] && context?.vector) { - const scoreSort = new ScoreSort({ - scoreVariable: context.vector.scoreVariable, - direction: options[SCORE_FIELD], - }); - nodeSortFields.push(scoreSort); + if (options[SCORE_FIELD]) { + if (context.vector) { + nodeSortFields.push( + new ScoreSort({ + scoreVariable: context.vector.scoreVariable, + direction: options[SCORE_FIELD], + }) + ); + } else if (context.fulltext) { + nodeSortFields.push( + new ScoreSort({ + scoreVariable: context.fulltext.scoreVariable, + direction: options[SCORE_FIELD], + }) + ); + } } return { @@ -107,12 +115,10 @@ export class SortAndPaginationFactory { optionArg, entity, context, - scoreVariable, }: { optionArg: GraphQLSortArg | NestedGraphQLSortArg; entity: EntityAdapter | RelationshipAdapter; context: Neo4jGraphQLTranslationContext; - scoreVariable?: Cypher.Variable; }): Sort[] { if (isUnionEntity(entity)) { return []; @@ -130,19 +136,10 @@ export class SortAndPaginationFactory { optionArg: optionArg[entity.propertiesTypeName] as GraphQLSortArg, entity, context, - scoreVariable, }); } return Object.entries(optionArg).map(([fieldName, sortDir]) => { - // TODO: fix conflict with a "score" fieldname - if (fieldName === SCORE_FIELD && scoreVariable) { - return new ScoreSort({ - scoreVariable, - direction: sortDir, - }); - } - const attribute = entity.findAttribute(fieldName); if (!attribute) { throw new Error(`no filter attribute ${fieldName}`); diff --git a/packages/graphql/src/translate/queryAST/factory/parsers/parse-operation-fields.ts b/packages/graphql/src/translate/queryAST/factory/parsers/parse-operation-fields.ts index 05d12701bb..c6a303617a 100644 --- a/packages/graphql/src/translate/queryAST/factory/parsers/parse-operation-fields.ts +++ b/packages/graphql/src/translate/queryAST/factory/parsers/parse-operation-fields.ts @@ -34,6 +34,7 @@ export type TopLevelOperationFieldMatch = | "UPDATE" | "DELETE" | "CUSTOM_CYPHER" + | "FULLTEXT" | "VECTOR"; export function parseTopLevelOperationField( @@ -44,6 +45,9 @@ export function parseTopLevelOperationField( if (!entityAdapter) { return "CUSTOM_CYPHER"; } + if (context.fulltext) { + return "FULLTEXT"; + } if (context.vector) { return "VECTOR"; } diff --git a/packages/graphql/src/translate/translate-top-level-match.ts b/packages/graphql/src/translate/translate-top-level-match.ts index 17839ad50f..2a70b8bb3e 100644 --- a/packages/graphql/src/translate/translate-top-level-match.ts +++ b/packages/graphql/src/translate/translate-top-level-match.ts @@ -19,7 +19,6 @@ import Cypher from "@neo4j/cypher-builder"; import type { Node } from "../classes"; -import { SCORE_FIELD } from "../constants"; import type { AuthorizationOperation } from "../schema-model/annotation/AuthorizationAnnotation"; import type { GraphQLWhereArg } from "../types"; import type { Neo4jGraphQLTranslationContext } from "../types/neo4j-graphql-translation-context"; @@ -75,29 +74,8 @@ function createMatchClause({ operation: AuthorizationOperation; where: GraphQLWhereArg | undefined; }): CreateMatchClauseReturn { - const { resolveTree } = context; - const fulltextInput = (resolveTree.args.fulltext || {}) as Record; - let matchClause: Cypher.Match | Cypher.Yield = new Cypher.Match(matchPattern); - let whereOperators: Cypher.Predicate[] = []; - - // TODO: removed deprecated fulltext translation - const entries = Object.entries(fulltextInput); - if (entries.length) { - if (entries.length > 1) { - throw new Error("Can only call one search at any given time"); - } - const [indexName, indexInput] = entries[0] as [string, { phrase: string }]; - const phraseParam = new Cypher.Param(indexInput.phrase); - - matchClause = Cypher.db.index.fulltext.queryNodes(indexName, phraseParam).yield(["node", matchNode]); - - whereOperators = node.getLabels(context).map((label) => { - return Cypher.in(new Cypher.Param(label), Cypher.labels(matchNode)); - }); - } else if (context.fulltext) { - ({ matchClause, whereOperators } = createFulltextMatchClause(matchNode, where, node, context)); - where = where?.[node.singular]; - } + const matchClause: Cypher.Match | Cypher.Yield = new Cypher.Match(matchPattern); + const whereOperators: Cypher.Predicate[] = []; let whereClause: Cypher.Match | Cypher.Yield | Cypher.With | undefined; @@ -166,51 +144,3 @@ function createMatchClause({ whereClause, }; } - -function createFulltextMatchClause( - matchNode: Cypher.Node, - whereInput: GraphQLWhereArg | undefined, - node: Node, - context: Neo4jGraphQLTranslationContext -): { - matchClause: Cypher.Yield; - whereOperators: Cypher.Predicate[]; -} { - if (!context.fulltext) { - throw new Error("Full-text context not defined"); - } - - // TODO: remove indexName assignment and undefined check once the name argument has been removed. - const indexName = context.fulltext.indexName || context.fulltext.name; - if (indexName === undefined) { - throw new Error("The name of the fulltext index should be defined using the indexName argument."); - } - const phraseParam = new Cypher.Param(context.resolveTree.args.phrase); - const scoreVar = context.fulltext.scoreVariable; - - const matchClause = Cypher.db.index.fulltext - .queryNodes(indexName, phraseParam) - .yield(["node", matchNode], ["score", scoreVar]); - - const expectedLabels = node.getLabels(context); - const labelsChecks = matchNode.hasLabels(...expectedLabels); - const whereOperators: Cypher.Predicate[] = []; - - if (whereInput?.[SCORE_FIELD]) { - if (whereInput[SCORE_FIELD].min || whereInput[SCORE_FIELD].min === 0) { - const scoreMinOp = Cypher.gte(scoreVar, new Cypher.Param(whereInput[SCORE_FIELD].min)); - if (scoreMinOp) whereOperators.push(scoreMinOp); - } - if (whereInput[SCORE_FIELD].max || whereInput[SCORE_FIELD].max === 0) { - const scoreMaxOp = Cypher.lte(scoreVar, new Cypher.Param(whereInput[SCORE_FIELD].max)); - if (scoreMaxOp) whereOperators.push(scoreMaxOp); - } - } - - if (labelsChecks) whereOperators.push(labelsChecks); - - return { - matchClause, - whereOperators, - }; -} diff --git a/packages/graphql/src/types/index.ts b/packages/graphql/src/types/index.ts index 9930f40331..e763ea6f37 100644 --- a/packages/graphql/src/types/index.ts +++ b/packages/graphql/src/types/index.ts @@ -28,6 +28,7 @@ import type { Neo4jGraphQLSubscriptionsCDCEngine } from "../classes/subscription import type { RelationshipNestedOperationsOption, RelationshipQueryDirectionOption } from "../constants"; import type { Neo4jGraphQLSchemaModel } from "../schema-model/Neo4jGraphQLSchemaModel"; import type { DefaultAnnotationValue } from "../schema-model/annotation/DefaultAnnotation"; +import type { FulltextField } from "../schema-model/annotation/FulltextAnnotation"; import type { VectorField } from "../schema-model/annotation/VectorAnnotation"; import type { RelationshipDirection } from "../schema-model/relationship/Relationship"; import type { JwtPayload } from "./jwt-payload"; @@ -44,11 +45,9 @@ export type AuthorizationContext = { }; export type FulltextContext = { - name: string | undefined; - fields: string[]; + index: FulltextField; + queryName: string; queryType: string; - queryName: string | undefined; - indexName: string | undefined; // TODO: not undefined once name is removed. scoreVariable: Cypher.Variable; }; diff --git a/packages/graphql/tests/integration/directives/fulltext/fulltext-argument.int.test.ts b/packages/graphql/tests/integration/directives/fulltext/fulltext-argument.int.test.ts index ddac17d3d3..1a9842d4e7 100644 --- a/packages/graphql/tests/integration/directives/fulltext/fulltext-argument.int.test.ts +++ b/packages/graphql/tests/integration/directives/fulltext/fulltext-argument.int.test.ts @@ -76,7 +76,7 @@ describe("@fulltext directive", () => { const type = testHelper.createUniqueType("Movie"); const typeDefs = /* GraphQL */ ` - type ${type.name} @fulltext(indexes: [{ name: "${indexName}", fields: ["title"] }]) @node { + type ${type.name} @fulltext(indexes: [{ indexName: "${indexName}", queryName: "${type.plural}ByTitle", fields: ["title"] }]) @node { title: String! } `; @@ -103,7 +103,7 @@ describe("@fulltext directive", () => { const type = testHelper.createUniqueType("Movie"); const typeDefs = /* GraphQL */ ` - type ${type.name} @fulltext(indexes: [{ name: "${indexName}", fields: ["title", "description"] }]) @node { + type ${type.name} @fulltext(indexes: [{ indexName: "${indexName}", queryName: "${type.plural}ByTitleAndDescription", fields: ["title", "description"] }]) @node { title: String! description: String! } @@ -136,7 +136,7 @@ describe("@fulltext directive", () => { const type = testHelper.createUniqueType("Movie"); const typeDefs = /* GraphQL */ ` - type ${type.name} @fulltext(indexes: [{ name: "${indexName}", fields: ["title", "description"] }]) @node { + type ${type.name} @fulltext(indexes: [{ indexName: "${indexName}", queryName: "${type.plural}ByTitleAndDescription", fields: ["title", "description"] }]) @node { title: String! description: String! @alias(property: "${alias}") } diff --git a/packages/graphql/tests/integration/directives/fulltext/fulltext-index.int.test.ts b/packages/graphql/tests/integration/directives/fulltext/fulltext-index.int.test.ts index da634668ac..9d03099201 100644 --- a/packages/graphql/tests/integration/directives/fulltext/fulltext-index.int.test.ts +++ b/packages/graphql/tests/integration/directives/fulltext/fulltext-index.int.test.ts @@ -75,7 +75,7 @@ describe("@fulltext directive - indexes constraints", () => { const type = testHelper.createUniqueType("Movie"); const typeDefs = /* GraphQL */ ` - type ${type.name} @fulltext(indexes: [{ indexName: "${indexName}", fields: ["title"] }]) @node { + type ${type.name} @fulltext(indexes: [{ indexName: "${indexName}", queryName: "${type.plural}ByTitle", fields: ["title"] }]) @node { title: String! } `; @@ -101,7 +101,7 @@ describe("@fulltext directive - indexes constraints", () => { const type = testHelper.createUniqueType("Movie"); const typeDefs = /* GraphQL */ ` - type ${type.name} @fulltext(indexes: [{ indexName: "${indexName}", fields: ["title", "description"] }]) @node { + type ${type.name} @fulltext(indexes: [{ indexName: "${indexName}", queryName: "${type.plural}ByTitleAndDescription", fields: ["title", "description"] }]) @node { title: String! description: String! } @@ -135,7 +135,7 @@ describe("@fulltext directive - indexes constraints", () => { const type = testHelper.createUniqueType("Movie"); const typeDefs = /* GraphQL */ ` - type ${type.name} @fulltext(indexes: [{ indexName: "${indexName}", fields: ["title", "description"] }]) @node { + type ${type.name} @fulltext(indexes: [{ indexName: "${indexName}", queryName: "${type.plural}ByTitleAndDescription", fields: ["title", "description"] }]) @node { title: String! description: String! @alias(property: "${alias}") } diff --git a/packages/graphql/tests/integration/directives/fulltext/fulltext-query.int.test.ts b/packages/graphql/tests/integration/directives/fulltext/fulltext-query.int.test.ts index 868c528edf..fc3977ca2b 100644 --- a/packages/graphql/tests/integration/directives/fulltext/fulltext-query.int.test.ts +++ b/packages/graphql/tests/integration/directives/fulltext/fulltext-query.int.test.ts @@ -17,7 +17,6 @@ * limitations under the License. */ -import { gql } from "graphql-tag"; import { type Driver } from "neo4j-driver"; import { generate } from "randomstring"; import type { Neo4jGraphQL } from "../../../../src/classes"; @@ -30,7 +29,7 @@ import { TestHelper } from "../../../utils/tests-helper"; function generatedTypeDefs(personType: UniqueType, movieType: UniqueType): string { return ` - type ${personType.name} @fulltext(indexes: [{ indexName: "${personType.name}Index", fields: ["name"] }]) @node { + type ${personType.name} @fulltext(indexes: [{ indexName: "${personType.name}Index", queryName: "${personType.plural}ByName", fields: ["name"] }]) @node { name: String! born: Int! actedInMovies: [${movieType.name}!]! @relationship(type: "ACTED_IN", direction: OUT) @@ -91,7 +90,6 @@ describe("@fulltext directive", () => { let neoSchema: Neo4jGraphQL; let personType: UniqueType; let movieType: UniqueType; - let personTypeLowerFirst: string; let queryType: string; const person1 = { @@ -126,8 +124,7 @@ describe("@fulltext directive", () => { personType = testHelper.createUniqueType("Person"); movieType = testHelper.createUniqueType("Movie"); - queryType = `${personType.plural}Fulltext${upperFirst(personType.name)}Index`; - personTypeLowerFirst = personType.singular; + queryType = `${personType.plural}ByName`; const typeDefs = generatedTypeDefs(personType, movieType); @@ -166,33 +163,35 @@ describe("@fulltext directive", () => { return; } - const query = ` + const query = /* GraphQL */ ` query { ${queryType}(phrase: "a different name") { - score - ${personTypeLowerFirst} { - name - } + edges { + score + node { + name + } + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect((gqlResult.data?.[queryType] as any[])[0][personTypeLowerFirst]).toEqual({ + expect((gqlResult.data?.[queryType] as any).edges[0].node).toEqual({ name: person2.name, }); - expect((gqlResult.data?.[queryType] as any[])[1][personTypeLowerFirst]).toEqual({ + expect((gqlResult.data?.[queryType] as any).edges[1].node).toEqual({ name: person1.name, }); - expect((gqlResult.data?.[queryType] as any[])[2][personTypeLowerFirst]).toEqual({ + expect((gqlResult.data?.[queryType] as any).edges[2].node).toEqual({ name: person3.name, }); - expect((gqlResult.data?.[queryType] as any[])[0][SCORE_FIELD]).toBeGreaterThanOrEqual( - (gqlResult.data?.[queryType] as any[])[1][SCORE_FIELD] + expect((gqlResult.data?.[queryType] as any).edges[0][SCORE_FIELD]).toBeGreaterThanOrEqual( + (gqlResult.data?.[queryType] as any).edges[1][SCORE_FIELD] ); - expect((gqlResult.data?.[queryType] as any[])[1][SCORE_FIELD]).toBeGreaterThanOrEqual( - (gqlResult.data?.[queryType] as any[])[2][SCORE_FIELD] + expect((gqlResult.data?.[queryType] as any).edges[1][SCORE_FIELD]).toBeGreaterThanOrEqual( + (gqlResult.data?.[queryType] as any).edges[2][SCORE_FIELD] ); }); @@ -203,33 +202,35 @@ describe("@fulltext directive", () => { return; } - const query = ` + const query = /* GraphQL */ ` query { ${queryType}(phrase: "some name") { - score - ${personTypeLowerFirst} { - name - } + edges { + score + node { + name + } + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect((gqlResult.data?.[queryType] as any[])[0][personTypeLowerFirst]).toEqual({ + expect((gqlResult.data?.[queryType] as any).edges[0].node).toEqual({ name: person3.name, }); - expect((gqlResult.data?.[queryType] as any[])[1][personTypeLowerFirst]).toEqual({ + expect((gqlResult.data?.[queryType] as any).edges[1].node).toEqual({ name: person1.name, }); - expect((gqlResult.data?.[queryType] as any[])[2][personTypeLowerFirst]).toEqual({ + expect((gqlResult.data?.[queryType] as any).edges[2].node).toEqual({ name: person2.name, }); - expect((gqlResult.data?.[queryType] as any[])[0][SCORE_FIELD]).toBeGreaterThanOrEqual( - (gqlResult.data?.[queryType] as any[])[1][SCORE_FIELD] + expect((gqlResult.data?.[queryType] as any).edges[0][SCORE_FIELD]).toBeGreaterThanOrEqual( + (gqlResult.data?.[queryType] as any).edges[1][SCORE_FIELD] ); - expect((gqlResult.data?.[queryType] as any[])[1][SCORE_FIELD]).toBeGreaterThanOrEqual( - (gqlResult.data?.[queryType] as any[])[2][SCORE_FIELD] + expect((gqlResult.data?.[queryType] as any).edges[1][SCORE_FIELD]).toBeGreaterThanOrEqual( + (gqlResult.data?.[queryType] as any).edges[2][SCORE_FIELD] ); }); @@ -240,20 +241,22 @@ describe("@fulltext directive", () => { return; } - const query = ` + const query = /* GraphQL */ ` query { ${queryType}(phrase: "should not match") { - score - ${personTypeLowerFirst} { - name - } + edges { + score + node { + name + } + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult.data?.[queryType]).toEqual([]); + expect((gqlResult.data?.[queryType] as any).edges).toEqual([]); }); test("Filters node to single result", async () => { @@ -263,22 +266,24 @@ describe("@fulltext directive", () => { return; } - const query = ` + const query = /* GraphQL */ ` query { - ${queryType}(phrase: "a different name", where: { ${personTypeLowerFirst}: { name_EQ: "${person1.name}" } }) { - score - ${personTypeLowerFirst} { - name - } + ${queryType}(phrase: "a different name", where: { node: { name_EQ: "${person1.name}" } }) { + edges { + score + node { + name + } + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect((gqlResult.data?.[queryType] as any[])[0][personTypeLowerFirst].name).toBe(person1.name); - expect((gqlResult.data?.[queryType] as any[])[0][SCORE_FIELD]).toBeNumber(); - expect(gqlResult.data?.[queryType] as any[]).toBeArrayOfSize(1); + expect((gqlResult.data?.[queryType] as any).edges[0].node.name).toBe(person1.name); + expect((gqlResult.data?.[queryType] as any).edges[0][SCORE_FIELD]).toBeNumber(); + expect((gqlResult.data?.[queryType] as any).edges).toBeArrayOfSize(1); }); test("Filters node to multiple results", async () => { @@ -288,26 +293,28 @@ describe("@fulltext directive", () => { return; } - const query = ` + const query = /* GraphQL */ ` query { - ${queryType}(phrase: "a different name", where: { ${personTypeLowerFirst}: { born_GTE: ${person2.born} } }) { - ${personTypeLowerFirst} { - name - } + ${queryType}(phrase: "a different name", where: { node: { born_GTE: ${person2.born} } }) { + edges { + node { + name + } + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult.data?.[queryType]).toEqual([ + expect((gqlResult.data?.[queryType] as any).edges).toEqual([ { - [personTypeLowerFirst]: { + node: { name: person2.name, }, }, { - [personTypeLowerFirst]: { + node: { name: person3.name, }, }, @@ -321,13 +328,15 @@ describe("@fulltext directive", () => { return; } - const query = ` + const query = /* GraphQL */ ` query { - ${queryType}(phrase: "a different name", where: { ${personTypeLowerFirst}: { name_CONTAINS: "not in anything!!" } }) { - score - ${personTypeLowerFirst} { - name - } + ${queryType}(phrase: "a different name", where: { node: { name_CONTAINS: "not in anything!!" } }) { + edges { + score + node { + name + } + } } } `; @@ -335,7 +344,7 @@ describe("@fulltext directive", () => { const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult.data?.[queryType]).toEqual([]); + expect((gqlResult.data?.[queryType] as any).edges).toEqual([]); }); test("Filters score to single result", async () => { @@ -345,22 +354,24 @@ describe("@fulltext directive", () => { return; } - const query = ` + const query = /* GraphQL */ ` query { ${queryType}(phrase: "a different name", where: { score: { min: 0.5 } }) { - score - ${personTypeLowerFirst} { - name - } + edges { + score + node { + name + } + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect((gqlResult.data?.[queryType] as any[])[0][personTypeLowerFirst].name).toBe(person2.name); - expect((gqlResult.data?.[queryType] as any[])[0][SCORE_FIELD]).toBeNumber(); - expect(gqlResult.data?.[queryType] as any[]).toBeArrayOfSize(1); + expect((gqlResult.data?.[queryType] as any).edges[0].node.name).toBe(person2.name); + expect((gqlResult.data?.[queryType] as any).edges[0][SCORE_FIELD]).toBeNumber(); + expect((gqlResult.data?.[queryType] as any).edges).toBeArrayOfSize(1); }); test("Filters score to multiple results", async () => { @@ -370,25 +381,27 @@ describe("@fulltext directive", () => { return; } - const query = ` + const query = /* GraphQL */ ` query { ${queryType}(phrase: "a different name", where: { score: { max: 0.5 } }) { - score - ${personTypeLowerFirst} { - name - } + edges { + score + node { + name + } + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect((gqlResult.data?.[queryType] as any[])[0][personTypeLowerFirst].name).toBe(person1.name); - expect((gqlResult.data?.[queryType] as any[])[1][personTypeLowerFirst].name).toBe(person3.name); - expect((gqlResult.data?.[queryType] as any[])[0][SCORE_FIELD]).toBeGreaterThanOrEqual( - (gqlResult.data?.[queryType] as any[])[1][SCORE_FIELD] + expect((gqlResult.data?.[queryType] as any).edges[0].node.name).toBe(person1.name); + expect((gqlResult.data?.[queryType] as any).edges[1].node.name).toBe(person3.name); + expect((gqlResult.data?.[queryType] as any).edges[0][SCORE_FIELD]).toBeGreaterThanOrEqual( + (gqlResult.data?.[queryType] as any).edges[1][SCORE_FIELD] ); - expect(gqlResult.data?.[queryType] as any[]).toBeArrayOfSize(2); + expect((gqlResult.data?.[queryType] as any).edges).toBeArrayOfSize(2); }); test("Filters score to no results", async () => { @@ -398,20 +411,22 @@ describe("@fulltext directive", () => { return; } - const query = ` + const query = /* GraphQL */ ` query { ${queryType}(phrase: "a different name", where: { score: { min: 100 } }) { - score - ${personTypeLowerFirst} { - name - } + edges { + score + node { + name + } + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult.data?.[queryType]).toEqual([]); + expect((gqlResult.data?.[queryType] as any).edges).toEqual([]); }); test("Filters score with combined min and max", async () => { @@ -421,22 +436,24 @@ describe("@fulltext directive", () => { return; } - const query = ` + const query = /* GraphQL */ ` query { ${queryType}(phrase: "a different name", where: { score: { min: 0.201, max: 0.57 } }) { - score - ${personTypeLowerFirst} { - name - } + edges { + score + node { + name + } + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect((gqlResult.data?.[queryType] as any[])[0][personTypeLowerFirst].name).toBe(person1.name); - expect((gqlResult.data?.[queryType] as any[])[0][SCORE_FIELD]).toBeNumber(); - expect(gqlResult.data?.[queryType] as any[]).toBeArrayOfSize(1); + expect((gqlResult.data?.[queryType] as any).edges[0].node.name).toBe(person1.name); + expect((gqlResult.data?.[queryType] as any).edges[0][SCORE_FIELD]).toBeNumber(); + expect((gqlResult.data?.[queryType] as any).edges).toBeArrayOfSize(1); }); test("Filters score with max score of 0", async () => { @@ -446,20 +463,22 @@ describe("@fulltext directive", () => { return; } - const query = ` + const query = /* GraphQL */ ` query { ${queryType}(phrase: "a different name", where: { score: { max: 0 } }) { - score - ${personTypeLowerFirst} { - name - } + edges { + score + node { + name + } + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult.data?.[queryType]).toEqual([]); + expect((gqlResult.data?.[queryType] as any).edges).toEqual([]); }); test("Throws error if score filtered with a non-number", async () => { @@ -470,13 +489,15 @@ describe("@fulltext directive", () => { } const nonNumberScoreInput = "not a number"; - const query = ` + const query = /* GraphQL */ ` query { ${queryType}(phrase: "a different name", where: { score: { max: "${nonNumberScoreInput}" } }) { - score - ${personTypeLowerFirst} { - name - } + edges { + score + node { + name + } + } } } `; @@ -496,30 +517,32 @@ describe("@fulltext directive", () => { const query = /* GraphQL */ ` query { - ${queryType}(phrase: "a different name", where: { ${personTypeLowerFirst}: { actedInMovies_SOME: { title_EQ: "${movie1.title}" } } }) { - score - ${personTypeLowerFirst} { - name - actedInMovies( sort: [{ released: DESC }] ) { - title - released - } - } + ${queryType}(phrase: "a different name", where: { node: { actedInMovies_SOME: { title_EQ: "${movie1.title}" } } }) { + edges { + score + node { + name + actedInMovies( sort: [{ released: DESC }] ) { + title + released + } + } + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect((gqlResult.data?.[queryType] as any[])[0][personTypeLowerFirst].name).toBe(person2.name); - expect((gqlResult.data?.[queryType] as any[])[0][personTypeLowerFirst].actedInMovies).toEqual([ + expect((gqlResult.data?.[queryType] as any).edges[0].node.name).toBe(person2.name); + expect((gqlResult.data?.[queryType] as any).edges[0].node.actedInMovies).toEqual([ { title: movie1.title, released: movie1.released, }, ]); - expect((gqlResult.data?.[queryType] as any[])[1][personTypeLowerFirst].name).toBe(person1.name); - expect((gqlResult.data?.[queryType] as any[])[1][personTypeLowerFirst].actedInMovies).toEqual([ + expect((gqlResult.data?.[queryType] as any).edges[1].node.name).toBe(person1.name); + expect((gqlResult.data?.[queryType] as any).edges[1].node.actedInMovies).toEqual([ { title: movie2.title, released: movie2.released, @@ -529,10 +552,10 @@ describe("@fulltext directive", () => { released: movie1.released, }, ]); - expect((gqlResult.data?.[queryType] as any[])[0][SCORE_FIELD]).toBeGreaterThanOrEqual( - (gqlResult.data?.[queryType] as any[])[1][SCORE_FIELD] + expect((gqlResult.data?.[queryType] as any).edges[0][SCORE_FIELD]).toBeGreaterThanOrEqual( + (gqlResult.data?.[queryType] as any).edges[1][SCORE_FIELD] ); - expect(gqlResult.data?.[queryType] as any[]).toBeArrayOfSize(2); + expect((gqlResult.data?.[queryType] as any).edges).toBeArrayOfSize(2); }); test("Filters a related node to a single value", async () => { @@ -542,25 +565,27 @@ describe("@fulltext directive", () => { return; } - const query = ` + const query = /* GraphQL */ ` query { - ${queryType}(phrase: "a different name", where: { ${personTypeLowerFirst}: { actedInMovies_ALL: { released_EQ: ${movie1.released} } } }) { - ${personTypeLowerFirst} { - name - actedInMovies { - title - released - } - } + ${queryType}(phrase: "a different name", where: { node: { actedInMovies_ALL: { released_EQ: ${movie1.released} } } }) { + edges { + node { + name + actedInMovies { + title + released + } + } + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult.data?.[queryType]).toEqual([ + expect((gqlResult.data?.[queryType] as any).edges).toEqual([ { - [personTypeLowerFirst]: { + node: { name: person2.name, actedInMovies: [ { @@ -582,22 +607,24 @@ describe("@fulltext directive", () => { const query = /* GraphQL */ ` query { - ${queryType}(phrase: "a different name", where: { ${personTypeLowerFirst}: { actedInMovies_ALL: { NOT: { released_IN: [${movie1.released}, ${movie2.released}] }} } }) { - score - ${personTypeLowerFirst} { - name - actedInMovies { - title - released - } - } + ${queryType}(phrase: "a different name", where: { node: { actedInMovies_ALL: { NOT: { released_IN: [${movie1.released}, ${movie2.released}] }} } }) { + edges { + score + node { + name + actedInMovies { + title + released + } + } + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult.data?.[queryType]).toEqual([]); + expect((gqlResult.data?.[queryType] as any).edges).toEqual([]); }); test("Throws an error for a non-string phrase", async () => { @@ -608,17 +635,19 @@ describe("@fulltext directive", () => { } const nonStringValue = '["not", "a", "string"]'; - const query = ` + const query = /* GraphQL */ ` query { ${queryType}(phrase: ${nonStringValue}) { - score - ${personTypeLowerFirst} { - name - actedInMovies { - title - released - } - } + edges { + score + node { + name + actedInMovies { + title + released + } + } + } } } `; @@ -637,17 +666,19 @@ describe("@fulltext directive", () => { } const invalidField = "not_a_field"; - const query = ` + const query = /* GraphQL */ ` query { - ${queryType}(phrase: "some name", where: { ${personTypeLowerFirst}: { ${invalidField}_EQ: "invalid" } }) { - score - ${personTypeLowerFirst} { - name - actedInMovies { - title - released - } - } + ${queryType}(phrase: "some name", where: { node: { ${invalidField}_EQ: "invalid" } }) { + edges { + score + node { + name + actedInMovies { + title + released + } + } + } } } `; @@ -665,27 +696,29 @@ describe("@fulltext directive", () => { return; } - const query = ` + const query = /* GraphQL */ ` query { ${queryType}(phrase: "a different name", sort: { score: ASC }) { - score - ${personTypeLowerFirst} { - name - } + edges { + score + node { + name + } + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect((gqlResult.data?.[queryType] as any[])[0][personTypeLowerFirst].name).toBe(person3.name); - expect((gqlResult.data?.[queryType] as any[])[1][personTypeLowerFirst].name).toBe(person1.name); - expect((gqlResult.data?.[queryType] as any[])[2][personTypeLowerFirst].name).toBe(person2.name); - expect((gqlResult.data?.[queryType] as any[])[0][SCORE_FIELD]).toBeLessThanOrEqual( - (gqlResult.data?.[queryType] as any[])[1][SCORE_FIELD] + expect((gqlResult.data?.[queryType] as any).edges[0].node.name).toBe(person3.name); + expect((gqlResult.data?.[queryType] as any).edges[1].node.name).toBe(person1.name); + expect((gqlResult.data?.[queryType] as any).edges[2].node.name).toBe(person2.name); + expect((gqlResult.data?.[queryType] as any).edges[0][SCORE_FIELD]).toBeLessThanOrEqual( + (gqlResult.data?.[queryType] as any).edges[1][SCORE_FIELD] ); - expect((gqlResult.data?.[queryType] as any[])[1][SCORE_FIELD]).toBeLessThanOrEqual( - (gqlResult.data?.[queryType] as any[])[2][SCORE_FIELD] + expect((gqlResult.data?.[queryType] as any).edges[1][SCORE_FIELD]).toBeLessThanOrEqual( + (gqlResult.data?.[queryType] as any).edges[2][SCORE_FIELD] ); }); @@ -696,25 +729,27 @@ describe("@fulltext directive", () => { return; } - const query = ` + const query = /* GraphQL */ ` query { - ${queryType}(phrase: "a different name", sort: [{ ${personTypeLowerFirst}: { name: ASC } }]) { - score - ${personTypeLowerFirst} { - name - } + ${queryType}(phrase: "a different name", sort: [{ node: { name: ASC } }]) { + edges { + score + node { + name + } + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect((gqlResult.data?.[queryType] as any[])[0][personTypeLowerFirst].name).toBe(person3.name); - expect((gqlResult.data?.[queryType] as any[])[1][personTypeLowerFirst].name).toBe(person2.name); - expect((gqlResult.data?.[queryType] as any[])[2][personTypeLowerFirst].name).toBe(person1.name); - expect((gqlResult.data?.[queryType] as any[])[0][SCORE_FIELD]).toBeNumber(); - expect((gqlResult.data?.[queryType] as any[])[1][SCORE_FIELD]).toBeNumber(); - expect((gqlResult.data?.[queryType] as any[])[2][SCORE_FIELD]).toBeNumber(); + expect((gqlResult.data?.[queryType] as any).edges[0].node.name).toBe(person3.name); + expect((gqlResult.data?.[queryType] as any).edges[1].node.name).toBe(person2.name); + expect((gqlResult.data?.[queryType] as any).edges[2].node.name).toBe(person1.name); + expect((gqlResult.data?.[queryType] as any).edges[0][SCORE_FIELD]).toBeNumber(); + expect((gqlResult.data?.[queryType] as any).edges[1][SCORE_FIELD]).toBeNumber(); + expect((gqlResult.data?.[queryType] as any).edges[2][SCORE_FIELD]).toBeNumber(); }); test("Unordered sorting", async () => { @@ -724,25 +759,27 @@ describe("@fulltext directive", () => { return; } - const query = ` + const query = /* GraphQL */ ` query { - ${queryType}(phrase: "this is", sort: { ${personTypeLowerFirst}: { born: ASC, name: DESC } }) { - ${personTypeLowerFirst} { - name - born - } + ${queryType}(phrase: "this is", sort: { node: { born: ASC, name: DESC } }) { + edges { + node { + name + born + } + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult.data?.[queryType]).toEqual([ + expect((gqlResult.data?.[queryType] as any).edges).toEqual([ { - [personTypeLowerFirst]: person1, + node: person1, }, { - [personTypeLowerFirst]: person2, + node: person2, }, ]); }); @@ -773,23 +810,27 @@ describe("@fulltext directive", () => { { person1, person2 } ); - const query1 = ` + const query1 = /* GraphQL */ ` query { - ${queryType}(phrase: "b", sort: [{ ${personTypeLowerFirst}: { born: DESC } }, { ${personTypeLowerFirst}: { name: ASC } }]) { - ${personTypeLowerFirst} { - name - born - } + ${queryType}(phrase: "b", sort: [{ node: { born: DESC } }, { node: { name: ASC } }]) { + edges { + node { + name + born + } + } } } `; - const query2 = ` + const query2 = /* GraphQL */ ` query { - ${queryType}(phrase: "b", sort: [{ ${personTypeLowerFirst}: { name: ASC } }, { ${personTypeLowerFirst}: { born: DESC } }]) { - ${personTypeLowerFirst} { - name - born - } + ${queryType}(phrase: "b", sort: [{ node: { name: ASC } }, { node: { born: DESC } }]) { + edges { + node { + name + born + } + } } } `; @@ -798,14 +839,8 @@ describe("@fulltext directive", () => { expect(gqlResult1.errors).toBeFalsy(); expect(gqlResult2.errors).toBeFalsy(); - expect(gqlResult1.data?.[queryType]).toEqual([ - { [personTypeLowerFirst]: person2 }, - { [personTypeLowerFirst]: person1 }, - ]); - expect(gqlResult2.data?.[queryType]).toEqual([ - { [personTypeLowerFirst]: person1 }, - { [personTypeLowerFirst]: person2 }, - ]); + expect((gqlResult1.data?.[queryType] as any).edges).toEqual([{ node: person2 }, { node: person1 }]); + expect((gqlResult2.data?.[queryType] as any).edges).toEqual([{ node: person1 }, { node: person2 }]); }); test("Ordered sorting, with score", async () => { @@ -834,25 +869,29 @@ describe("@fulltext directive", () => { { person1, person2 } ); - const query1 = ` + const query1 = /* GraphQL */ ` query { - ${queryType}(phrase: "b d", sort: [{ score: DESC }, { ${personTypeLowerFirst}: { name: ASC } }]) { - score - ${personTypeLowerFirst} { - name - born - } + ${queryType}(phrase: "b d", sort: [{ score: DESC }, { node: { name: ASC } }]) { + edges { + score + node { + name + born + } + } } } `; - const query2 = ` + const query2 = /* GraphQL */ ` query { - ${queryType}(phrase: "b d", sort: [{ ${personTypeLowerFirst}: { name: ASC } }, { score: DESC }]) { - score - ${personTypeLowerFirst} { - name - born - } + ${queryType}(phrase: "b d", sort: [{ node: { name: ASC } }, { score: DESC }]) { + edges { + score + node { + name + born + } + } } } `; @@ -861,14 +900,17 @@ describe("@fulltext directive", () => { expect(gqlResult1.errors).toBeFalsy(); expect(gqlResult2.errors).toBeFalsy(); - expect((gqlResult1.data?.[queryType] as any[])[0][personTypeLowerFirst]).toEqual(person2); - expect((gqlResult1.data?.[queryType] as any[])[0][SCORE_FIELD]).toBeNumber(); - expect((gqlResult1.data?.[queryType] as any[])[1][personTypeLowerFirst]).toEqual(person1); - expect((gqlResult1.data?.[queryType] as any[])[1][SCORE_FIELD]).toBeNumber(); - expect((gqlResult2.data?.[queryType] as any[])[0][personTypeLowerFirst]).toEqual(person1); - expect((gqlResult2.data?.[queryType] as any[])[0][SCORE_FIELD]).toBeNumber(); - expect((gqlResult2.data?.[queryType] as any[])[1][personTypeLowerFirst]).toEqual(person2); - expect((gqlResult2.data?.[queryType] as any[])[1][SCORE_FIELD]).toBeNumber(); + expect((gqlResult1.data?.[queryType] as any).edges[0].node).toEqual(person2); + expect((gqlResult1.data?.[queryType] as any).edges[0][SCORE_FIELD]).toBeNumber(); + expect((gqlResult1.data?.[queryType] as any).edges[1].node).toEqual(person1); + expect((gqlResult1.data?.[queryType] as any).edges[1][SCORE_FIELD]).toBeNumber(); + expect((gqlResult1.data?.[queryType] as any).edges[0][SCORE_FIELD]).toBeGreaterThan( + (gqlResult1.data?.[queryType] as any).edges[1][SCORE_FIELD] + ); + expect((gqlResult2.data?.[queryType] as any).edges[0].node).toEqual(person1); + expect((gqlResult2.data?.[queryType] as any).edges[0][SCORE_FIELD]).toBeNumber(); + expect((gqlResult2.data?.[queryType] as any).edges[1].node).toEqual(person2); + expect((gqlResult2.data?.[queryType] as any).edges[1][SCORE_FIELD]).toBeNumber(); }); test("Sort on nested field", async () => { @@ -881,22 +923,24 @@ describe("@fulltext directive", () => { const query = /* GraphQL */ ` query { ${queryType}(phrase: "a name") { - ${personTypeLowerFirst} { - name - actedInMovies(sort: [{ released: ASC }]) { - title - released - } - } + edges { + node { + name + actedInMovies(sort: [{ released: ASC }]) { + title + released + } + } + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult.data?.[queryType]).toEqual([ + expect((gqlResult.data?.[queryType] as any).edges).toEqual([ { - [personTypeLowerFirst]: { + node: { name: person1.name, actedInMovies: [ { @@ -911,7 +955,7 @@ describe("@fulltext directive", () => { }, }, { - [personTypeLowerFirst]: { + node: { name: person2.name, actedInMovies: [ { @@ -922,7 +966,7 @@ describe("@fulltext directive", () => { }, }, { - [personTypeLowerFirst]: { + node: { name: person3.name, actedInMovies: [ { @@ -942,26 +986,28 @@ describe("@fulltext directive", () => { return; } - const query = ` + const query = /* GraphQL */ ` query { ${queryType}(phrase: "a name", sort: { score: ASC }, where: { score: { min: 0.2 } }) { - score - ${personTypeLowerFirst} { - name - born - } + edges { + score + node { + name + born + } + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect((gqlResult.data?.[queryType] as any[])[0][personTypeLowerFirst]).toEqual(person2); - expect((gqlResult.data?.[queryType] as any[])[1][personTypeLowerFirst]).toEqual(person1); - expect((gqlResult.data?.[queryType] as any[])[0][SCORE_FIELD]).toBeLessThanOrEqual( - (gqlResult.data?.[queryType] as any[])[1][SCORE_FIELD] + expect((gqlResult.data?.[queryType] as any).edges[0].node).toEqual(person2); + expect((gqlResult.data?.[queryType] as any).edges[1].node).toEqual(person1); + expect((gqlResult.data?.[queryType] as any).edges[0][SCORE_FIELD]).toBeLessThanOrEqual( + (gqlResult.data?.[queryType] as any).edges[1][SCORE_FIELD] ); - expect(gqlResult.data?.[queryType] as any[]).toBeArrayOfSize(2); + expect((gqlResult.data?.[queryType] as any).edges).toBeArrayOfSize(2); }); test("Limiting is possible", async () => { @@ -971,73 +1017,22 @@ describe("@fulltext directive", () => { return; } - const query = ` - query { - ${queryType}(phrase: "a name", limit: 2) { - ${personTypeLowerFirst} { - name - born - } - } - } - `; - const gqlResult = await testHelper.executeGraphQL(query); - - expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult.data?.[queryType]).toBeArrayOfSize(2); - }); - - test("Offsetting is possible", async () => { - // Skip if multi-db not supported - if (!MULTIDB_SUPPORT) { - console.log("MULTIDB_SUPPORT NOT AVAILABLE - SKIPPING"); - return; - } - - const query = ` - query { - ${queryType}(phrase: "a name", offset: 2) { - ${personTypeLowerFirst} { - name - born - } - } - } - `; - const gqlResult = await testHelper.executeGraphQL(query); - - expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult.data?.[queryType]).toEqual([ - { - [personTypeLowerFirst]: person3, - }, - ]); - }); - - test("Combined limiting and offsetting is possible", async () => { - // Skip if multi-db not supported - if (!MULTIDB_SUPPORT) { - console.log("MULTIDB_SUPPORT NOT AVAILABLE - SKIPPING"); - return; - } - - const query = ` + const query = /* GraphQL */ ` query { - ${queryType}(phrase: "a name", limit: 1, offset: 1) { - score - ${personTypeLowerFirst} { - name - born - } + ${queryType}(phrase: "a name", first: 2) { + edges { + node { + name + born + } + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect((gqlResult.data?.[queryType] as any[])[0][personTypeLowerFirst]).toEqual(person2); - expect((gqlResult.data?.[queryType] as any[])[0][SCORE_FIELD]).toBeNumber(); - expect(gqlResult.data?.[queryType] as any[]).toBeArrayOfSize(1); + expect((gqlResult.data?.[queryType] as any).edges).toBeArrayOfSize(2); }); test("Sorting by score when the score is not returned", async () => { @@ -1047,31 +1042,33 @@ describe("@fulltext directive", () => { return; } - const query = ` + const query = /* GraphQL */ ` query { ${queryType}(phrase: "a different name", sort: { score: ASC }) { - ${personTypeLowerFirst} { - name - } + edges { + node { + name + } + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult.data?.[queryType]).toEqual([ + expect((gqlResult.data?.[queryType] as any).edges).toEqual([ { - [personTypeLowerFirst]: { + node: { name: person3.name, }, }, { - [personTypeLowerFirst]: { + node: { name: person1.name, }, }, { - [personTypeLowerFirst]: { + node: { name: person2.name, }, }, @@ -1085,21 +1082,23 @@ describe("@fulltext directive", () => { return; } - const query = ` + const query = /* GraphQL */ ` query { - ${queryType}(phrase: "this is", sort: { ${personTypeLowerFirst}: { born: ASC } }) { - score + ${queryType}(phrase: "this is", sort: { node: { born: ASC } }) { + edges { + score + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect((gqlResult.data?.[queryType] as any[])[0][SCORE_FIELD]).toBeNumber(); - expect((gqlResult.data?.[queryType] as any[])[1][SCORE_FIELD]).toBeNumber(); - expect((gqlResult.data?.[queryType] as any[])[0][personTypeLowerFirst]).toBeUndefined(); - expect((gqlResult.data?.[queryType] as any[])[1][personTypeLowerFirst]).toBeUndefined(); - expect(gqlResult.data?.[queryType] as any[]).toBeArrayOfSize(2); + expect((gqlResult.data?.[queryType] as any).edges[0][SCORE_FIELD]).toBeNumber(); + expect((gqlResult.data?.[queryType] as any).edges[1][SCORE_FIELD]).toBeNumber(); + expect((gqlResult.data?.[queryType] as any).edges[0].node).toBeUndefined(); + expect((gqlResult.data?.[queryType] as any).edges[1].node).toBeUndefined(); + expect((gqlResult.data?.[queryType] as any).edges).toBeArrayOfSize(2); }); test("Filters by node when node is not returned", async () => { @@ -1109,19 +1108,21 @@ describe("@fulltext directive", () => { return; } - const query = ` + const query = /* GraphQL */ ` query { - ${queryType}(phrase: "a different name", where: { ${personTypeLowerFirst}: { name_EQ: "${person1.name}" } }) { - score + ${queryType}(phrase: "a different name", where: { node: { name_EQ: "${person1.name}" } }) { + edges { + score + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect((gqlResult.data?.[queryType] as any[])[0][SCORE_FIELD]).toBeNumber(); - expect((gqlResult.data?.[queryType] as any[])[0][personTypeLowerFirst]).toBeUndefined(); - expect(gqlResult.data?.[queryType] as any[]).toBeArrayOfSize(1); + expect((gqlResult.data?.[queryType] as any).edges[0][SCORE_FIELD]).toBeNumber(); + expect((gqlResult.data?.[queryType] as any).edges[0].node).toBeUndefined(); + expect((gqlResult.data?.[queryType] as any).edges).toBeArrayOfSize(1); }); test("Filters by score when no score is returned", async () => { @@ -1131,26 +1132,28 @@ describe("@fulltext directive", () => { return; } - const query = ` + const query = /* GraphQL */ ` query { ${queryType}(phrase: "a different name", where: { score: { max: 0.5 } }) { - ${personTypeLowerFirst} { - name - } + edges { + node { + name + } + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult.data?.[queryType]).toEqual([ + expect((gqlResult.data?.[queryType] as any).edges).toEqual([ { - [personTypeLowerFirst]: { + node: { name: person1.name, }, }, { - [personTypeLowerFirst]: { + node: { name: person3.name, }, }, @@ -1161,7 +1164,6 @@ describe("@fulltext directive", () => { let neoSchema: Neo4jGraphQL; let personType: UniqueType; let movieType: UniqueType; - let personTypeLowerFirst: string; let queryType: string; const person1 = { @@ -1196,8 +1198,7 @@ describe("@fulltext directive", () => { personType = testHelper.createUniqueType("Person"); movieType = testHelper.createUniqueType("Movie"); - queryType = `${personType.plural}Fulltext${upperFirst(personType.name)}Index`; - personTypeLowerFirst = personType.singular; + queryType = `${personType.plural}ByName`; await testHelper.executeCypher( ` @@ -1222,8 +1223,8 @@ describe("@fulltext directive", () => { return; } - const typeDefs = ` - type ${personType.name} @node @fulltext(indexes: [{ indexName: "${personType.name}Index", fields: ["name"] }]) + const typeDefs = /* GraphQL */ ` + type ${personType.name} @node @fulltext(indexes: [{ indexName: "${personType.name}Index", queryName: "${queryType}", fields: ["name"] }]) @authorization(filter: [{ where: { node: { name_EQ: "$jwt.name" } } }]) { name: String! born: Int! @@ -1255,13 +1256,15 @@ describe("@fulltext directive", () => { sessionConfig: { database: databaseName }, }); - const query = ` + const query = /* GraphQL */ ` query { ${queryType}(phrase: "a name") { - score - ${personTypeLowerFirst} { - name - } + edges { + score + node { + name + } + } } } `; @@ -1271,11 +1274,11 @@ describe("@fulltext directive", () => { const gqlResult = await testHelper.executeGraphQLWithToken(query, token); expect(gqlResult.errors).toBeFalsy(); - expect((gqlResult.data?.[queryType] as any[])[0][personTypeLowerFirst]).toEqual({ + expect((gqlResult.data?.[queryType] as any).edges[0].node).toEqual({ name: person1.name, }); - expect((gqlResult.data?.[queryType] as any[])[0][SCORE_FIELD]).toBeNumber(); - expect(gqlResult.data?.[queryType] as any[]).toBeArrayOfSize(1); + expect((gqlResult.data?.[queryType] as any).edges[0][SCORE_FIELD]).toBeNumber(); + expect((gqlResult.data?.[queryType] as any).edges).toBeArrayOfSize(1); }); test("Works with @auth 'where' when unauthenticated", async () => { @@ -1285,8 +1288,8 @@ describe("@fulltext directive", () => { return; } - const typeDefs = ` - type ${personType.name} @node @fulltext(indexes: [{ indexName: "${personType.name}Index", fields: ["name"] }]) + const typeDefs = /* GraphQL */ ` + type ${personType.name} @node @fulltext(indexes: [{ indexName: "${personType.name}Index", queryName: "${queryType}", fields: ["name"] }]) @authorization(filter: [{ where: { node: { name_EQ: "$jwt.name" } } }]) { name: String! born: Int! @@ -1318,13 +1321,15 @@ describe("@fulltext directive", () => { sessionConfig: { database: databaseName }, }); - const query = ` + const query = /* GraphQL */ ` query { ${queryType}(phrase: "a name") { - score - ${personTypeLowerFirst} { - name - } + edges { + score + node { + name + } + } } } `; @@ -1334,7 +1339,7 @@ describe("@fulltext directive", () => { const gqlResult = await testHelper.executeGraphQLWithToken(query, token); expect(gqlResult.errors).toBeFalsy(); - expect(gqlResult.data?.[queryType] as any[]).toBeArrayOfSize(0); + expect((gqlResult.data?.[queryType] as any).edges).toBeArrayOfSize(0); }); test("Works with @auth 'roles' when authenticated", async () => { @@ -1344,12 +1349,12 @@ describe("@fulltext directive", () => { return; } - const typeDefs = ` + const typeDefs = /* GraphQL */ ` type JWTPayload @jwt { roles: [String!]! } - type ${personType.name} @node @fulltext(indexes: [{ indexName: "${personType.name}Index", fields: ["name"] }]) + type ${personType.name} @node @fulltext(indexes: [{ indexName: "${personType.name}Index", queryName: "${queryType}", fields: ["name"] }]) @authorization(validate: [{ where: { jwt: { roles_INCLUDES: "admin" } } }]) { name: String! born: Int! @@ -1381,13 +1386,15 @@ describe("@fulltext directive", () => { sessionConfig: { database: databaseName }, }); - const query = ` + const query = /* GraphQL */ ` query { ${queryType}(phrase: "a name") { - score - ${personTypeLowerFirst} { - name - } + edges { + score + node { + name + } + } } } `; @@ -1397,22 +1404,22 @@ describe("@fulltext directive", () => { const gqlResult = await testHelper.executeGraphQLWithToken(query, token); expect(gqlResult.errors).toBeFalsy(); - expect((gqlResult.data?.[queryType] as any[])[0][personTypeLowerFirst]).toEqual({ + expect((gqlResult.data?.[queryType] as any).edges[0].node).toEqual({ name: person1.name, }); - expect((gqlResult.data?.[queryType] as any[])[1][personTypeLowerFirst]).toEqual({ + expect((gqlResult.data?.[queryType] as any).edges[1].node).toEqual({ name: person2.name, }); - expect((gqlResult.data?.[queryType] as any[])[2][personTypeLowerFirst]).toEqual({ + expect((gqlResult.data?.[queryType] as any).edges[2].node).toEqual({ name: person3.name, }); - expect((gqlResult.data?.[queryType] as any[])[0][SCORE_FIELD]).toBeGreaterThanOrEqual( - (gqlResult.data?.[queryType] as any[])[1][SCORE_FIELD] + expect((gqlResult.data?.[queryType] as any).edges[0][SCORE_FIELD]).toBeGreaterThanOrEqual( + (gqlResult.data?.[queryType] as any).edges[1][SCORE_FIELD] ); - expect((gqlResult.data?.[queryType] as any[])[1][SCORE_FIELD]).toBeGreaterThanOrEqual( - (gqlResult.data?.[queryType] as any[])[2][SCORE_FIELD] + expect((gqlResult.data?.[queryType] as any).edges[1][SCORE_FIELD]).toBeGreaterThanOrEqual( + (gqlResult.data?.[queryType] as any).edges[2][SCORE_FIELD] ); - expect(gqlResult.data?.[queryType] as any[]).toBeArrayOfSize(3); + expect((gqlResult.data?.[queryType] as any).edges).toBeArrayOfSize(3); }); test("Works with @auth 'roles' when unauthenticated", async () => { @@ -1422,12 +1429,12 @@ describe("@fulltext directive", () => { return; } - const typeDefs = ` + const typeDefs = /* GraphQL */ ` type JWTPayload @jwt { roles: [String!]! } - type ${personType.name} @node @fulltext(indexes: [{ indexName: "${personType.name}Index", fields: ["name"] }]) + type ${personType.name} @node @fulltext(indexes: [{ indexName: "${personType.name}Index", queryName: "${queryType}", fields: ["name"] }]) @authorization(validate: [{ where: { jwt: { roles_INCLUDES: "admin" } } }]) { name: String! born: Int! @@ -1459,13 +1466,15 @@ describe("@fulltext directive", () => { sessionConfig: { database: databaseName }, }); - const query = ` + const query = /* GraphQL */ ` query { ${queryType}(phrase: "a name") { - score - ${personTypeLowerFirst} { - name - } + edges { + score + node { + name + } + } } } `; @@ -1484,8 +1493,8 @@ describe("@fulltext directive", () => { return; } - const typeDefs = ` - type ${personType.name} @node @fulltext(indexes: [{ indexName: "${personType.name}Index", fields: ["name"] }]) + const typeDefs = /* GraphQL */ ` + type ${personType.name} @node @fulltext(indexes: [{ indexName: "${personType.name}Index", queryName: "${queryType}", fields: ["name"] }]) @authorization(validate: [{ when: BEFORE, where: { node: { name_EQ: "$jwt.name" } } }]) { name: String! born: Int! @@ -1517,13 +1526,15 @@ describe("@fulltext directive", () => { sessionConfig: { database: databaseName }, }); - const query = ` + const query = /* GraphQL */ ` query { - ${queryType}(phrase: "a name", where: { ${personTypeLowerFirst}: { name_EQ: "${person2.name}" } }) { - score - ${personTypeLowerFirst} { - name - } + ${queryType}(phrase: "a name", where: { node: { name_EQ: "${person2.name}" } }) { + edges { + score + node { + name + } + } } } `; @@ -1533,9 +1544,9 @@ describe("@fulltext directive", () => { const gqlResult = await testHelper.executeGraphQLWithToken(query, token); expect(gqlResult.errors).toBeFalsy(); - expect((gqlResult.data?.[queryType] as any[])[0][personTypeLowerFirst].name).toBe(person2.name); - expect((gqlResult.data?.[queryType] as any[])[0][SCORE_FIELD]).toBeNumber(); - expect(gqlResult.data?.[queryType] as any[]).toBeArrayOfSize(1); + expect((gqlResult.data?.[queryType] as any).edges[0].node.name).toBe(person2.name); + expect((gqlResult.data?.[queryType] as any).edges[0][SCORE_FIELD]).toBeNumber(); + expect((gqlResult.data?.[queryType] as any).edges).toBeArrayOfSize(1); }); test("Works with @auth 'allow' when one match", async () => { @@ -1545,8 +1556,8 @@ describe("@fulltext directive", () => { return; } - const typeDefs = ` - type ${personType.name} @node @fulltext(indexes: [{ indexName: "${personType.name}Index", fields: ["name"] }]) + const typeDefs = /* GraphQL */ ` + type ${personType.name} @node @fulltext(indexes: [{ indexName: "${personType.name}Index", queryName: "${queryType}", fields: ["name"] }]) @authorization(validate: [{ when: BEFORE, where: { node: { name_EQ: "$jwt.name" } } }]) { name: String! born: Int! @@ -1578,13 +1589,15 @@ describe("@fulltext directive", () => { sessionConfig: { database: databaseName }, }); - const query = ` + const query = /* GraphQL */ ` query { ${queryType}(phrase: "a name") { - score - ${personTypeLowerFirst} { - name - } + edges { + score + node { + name + } + } } } `; @@ -1603,12 +1616,12 @@ describe("@fulltext directive", () => { return; } - const typeDefs = ` + const typeDefs = /* GraphQL */ ` type JWTPayload @jwt { roles: [String!]! } - type ${personType.name} @node @fulltext(indexes: [{ indexName: "${personType.name}Index", fields: ["name"] }]) + type ${personType.name} @node @fulltext(indexes: [{ indexName: "${personType.name}Index", queryName: "${queryType}", fields: ["name"] }]) @authorization(validate: [{ operations: [READ], where: { jwt: { roles_INCLUDES: "admin" } } }]) { name: String! born: Int! @@ -1640,13 +1653,15 @@ describe("@fulltext directive", () => { sessionConfig: { database: databaseName }, }); - const query = ` + const query = /* GraphQL */ ` query { ${queryType}(phrase: "a name") { - score - ${personTypeLowerFirst} { - name - } + edges { + score + node { + name + } + } } } `; @@ -1665,16 +1680,15 @@ describe("@fulltext directive", () => { return; } - const moveTypeLowerFirst = movieType.singular; queryType = `${movieType.plural}Fulltext${upperFirst(movieType.name)}Index`; - const typeDefs = ` + const typeDefs = /* GraphQL */ ` type ${personType.name} @node { name: String! born: Int! actedInMovies: [${movieType.name}!]! @relationship(type: "ACTED_IN", direction: OUT) } - type ${movieType.name} @node @fulltext(indexes: [{ indexName: "${movieType.name}Index", fields: ["title", "description"] }]) { + type ${movieType.name} @node @fulltext(indexes: [{ indexName: "${movieType.name}Index", queryName: "${movieType.plural}ByTitleAndDescription", fields: ["title", "description"] }]) { title: String! description: String released: Int! @@ -1694,30 +1708,34 @@ describe("@fulltext directive", () => { sessionConfig: { database: databaseName }, }); - const query = ` + const query = /* GraphQL */ ` query { - ${queryType}(phrase: "some description") { - score - ${moveTypeLowerFirst} { - title - description - } + ${movieType.plural}ByTitleAndDescription(phrase: "some description") { + edges { + score + node { + title + description + } + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect((gqlResult.data?.[queryType] as any[])[0][moveTypeLowerFirst]).toEqual({ + expect((gqlResult.data?.[`${movieType.plural}ByTitleAndDescription`] as any).edges[0].node).toEqual({ title: movie1.title, description: movie1.description, }); - expect((gqlResult.data?.[queryType] as any[])[1][moveTypeLowerFirst]).toEqual({ + expect((gqlResult.data?.[`${movieType.plural}ByTitleAndDescription`] as any).edges[1].node).toEqual({ title: movie2.title, description: movie2.description, }); - expect((gqlResult.data?.[queryType] as any[])[0][SCORE_FIELD]).toBeGreaterThanOrEqual( - (gqlResult.data?.[queryType] as any[])[1][SCORE_FIELD] + expect( + (gqlResult.data?.[`${movieType.plural}ByTitleAndDescription`] as any).edges[0][SCORE_FIELD] + ).toBeGreaterThanOrEqual( + (gqlResult.data?.[`${movieType.plural}ByTitleAndDescription`] as any).edges[1][SCORE_FIELD] ); }); @@ -1729,7 +1747,6 @@ describe("@fulltext directive", () => { } personType = testHelper.createUniqueType("Person"); - personTypeLowerFirst = personType.singular; queryType = "CustomQueryName"; await testHelper.executeCypher( @@ -1744,7 +1761,7 @@ describe("@fulltext directive", () => { { person1, person2, person3 } ); - const typeDefs = ` + const typeDefs = /* GraphQL */ ` type ${personType.name} @node @fulltext(indexes: [{ queryName: "${queryType}", indexName: "${personType.name}CustomIndex", fields: ["name"] }]) { name: String! born: Int! @@ -1763,33 +1780,35 @@ describe("@fulltext directive", () => { sessionConfig: { database: databaseName }, }); - const query = ` + const query = /* GraphQL */ ` query { ${queryType}(phrase: "a different name") { - score - ${personTypeLowerFirst} { - name - } + edges { + score + node { + name + } + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect((gqlResult.data?.[queryType] as any[])[0][personTypeLowerFirst]).toEqual({ + expect((gqlResult.data?.[queryType] as any).edges[0].node).toEqual({ name: person2.name, }); - expect((gqlResult.data?.[queryType] as any[])[1][personTypeLowerFirst]).toEqual({ + expect((gqlResult.data?.[queryType] as any).edges[1].node).toEqual({ name: person1.name, }); - expect((gqlResult.data?.[queryType] as any[])[2][personTypeLowerFirst]).toEqual({ + expect((gqlResult.data?.[queryType] as any).edges[2].node).toEqual({ name: person3.name, }); - expect((gqlResult.data?.[queryType] as any[])[0][SCORE_FIELD]).toBeGreaterThanOrEqual( - (gqlResult.data?.[queryType] as any[])[1][SCORE_FIELD] + expect((gqlResult.data?.[queryType] as any).edges[0][SCORE_FIELD]).toBeGreaterThanOrEqual( + (gqlResult.data?.[queryType] as any).edges[1][SCORE_FIELD] ); - expect((gqlResult.data?.[queryType] as any[])[1][SCORE_FIELD]).toBeGreaterThanOrEqual( - (gqlResult.data?.[queryType] as any[])[2][SCORE_FIELD] + expect((gqlResult.data?.[queryType] as any).edges[1][SCORE_FIELD]).toBeGreaterThanOrEqual( + (gqlResult.data?.[queryType] as any).edges[2][SCORE_FIELD] ); }); @@ -1800,9 +1819,8 @@ describe("@fulltext directive", () => { return; } - const moveTypeLowerFirst = movieType.singular; queryType = "SomeCustomQueryName"; - const typeDefs = ` + const typeDefs = /* GraphQL */ ` type ${movieType.name} @node @fulltext(indexes: [{ queryName: "${queryType}", indexName: "${movieType.name}Index", fields: ["title", "description"] }]) { title: String! description: String @@ -1822,30 +1840,32 @@ describe("@fulltext directive", () => { sessionConfig: { database: databaseName }, }); - const query = ` + const query = /* GraphQL */ ` query { ${queryType}(phrase: "some description") { - score - ${moveTypeLowerFirst} { - title - description - } + edges { + score + node { + title + description + } + } } } `; const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); - expect((gqlResult.data?.[queryType] as any[])[0][moveTypeLowerFirst]).toEqual({ + expect((gqlResult.data?.[queryType] as any).edges[0].node).toEqual({ title: movie1.title, description: movie1.description, }); - expect((gqlResult.data?.[queryType] as any[])[1][moveTypeLowerFirst]).toEqual({ + expect((gqlResult.data?.[queryType] as any).edges[1].node).toEqual({ title: movie2.title, description: movie2.description, }); - expect((gqlResult.data?.[queryType] as any[])[0][SCORE_FIELD]).toBeGreaterThanOrEqual( - (gqlResult.data?.[queryType] as any[])[1][SCORE_FIELD] + expect((gqlResult.data?.[queryType] as any).edges[0][SCORE_FIELD]).toBeGreaterThanOrEqual( + (gqlResult.data?.[queryType] as any).edges[1][SCORE_FIELD] ); }); @@ -1857,7 +1877,6 @@ describe("@fulltext directive", () => { } movieType = testHelper.createUniqueType("Movie"); - const movieTypeLowerFirst = movieType.singular; const queryType1 = "CustomQueryName"; const queryType2 = "CustomQueryName2"; @@ -1871,7 +1890,7 @@ describe("@fulltext directive", () => { { movie1, movie2 } ); - const typeDefs = ` + const typeDefs = /* GraphQL */ ` type ${movieType.name} @node @fulltext(indexes: [ { queryName: "${queryType1}", indexName: "${movieType.name}CustomIndex", fields: ["title"] }, { queryName: "${queryType2}", indexName: "${movieType.name}CustomIndex2", fields: ["description"] } @@ -1894,23 +1913,27 @@ describe("@fulltext directive", () => { sessionConfig: { database: databaseName }, }); - const query1 = ` + const query1 = /* GraphQL */ ` query { ${queryType1}(phrase: "some title") { - score - ${movieTypeLowerFirst} { - title - } + edges { + score + node { + title + } + } } } `; - const query2 = ` + const query2 = /* GraphQL */ ` query { ${queryType2}(phrase: "some description") { - score - ${movieTypeLowerFirst} { - title - } + edges { + score + node { + title + } + } } } `; @@ -1918,25 +1941,25 @@ describe("@fulltext directive", () => { const gqlResult2 = await testHelper.executeGraphQL(query2); expect(gqlResult1.errors).toBeFalsy(); - expect((gqlResult1.data?.[queryType1] as any[])[0][movieTypeLowerFirst]).toEqual({ + expect((gqlResult1.data?.[queryType1] as any).edges[0].node).toEqual({ title: movie1.title, }); - expect((gqlResult1.data?.[queryType1] as any[])[1][movieTypeLowerFirst]).toEqual({ + expect((gqlResult1.data?.[queryType1] as any).edges[1].node).toEqual({ title: movie2.title, }); - expect((gqlResult1.data?.[queryType1] as any[])[0][SCORE_FIELD]).toBeGreaterThanOrEqual( - (gqlResult1.data?.[queryType1] as any[])[1][SCORE_FIELD] + expect((gqlResult1.data?.[queryType1] as any).edges[0][SCORE_FIELD]).toBeGreaterThanOrEqual( + (gqlResult1.data?.[queryType1] as any).edges[1][SCORE_FIELD] ); expect(gqlResult2.errors).toBeFalsy(); - expect((gqlResult2.data?.[queryType2] as any[])[0][movieTypeLowerFirst]).toEqual({ + expect((gqlResult2.data?.[queryType2] as any).edges[0].node).toEqual({ title: movie1.title, }); - expect((gqlResult2.data?.[queryType2] as any[])[1][movieTypeLowerFirst]).toEqual({ + expect((gqlResult2.data?.[queryType2] as any).edges[1].node).toEqual({ title: movie2.title, }); - expect((gqlResult2.data?.[queryType2] as any[])[0][SCORE_FIELD]).toBeGreaterThanOrEqual( - (gqlResult2.data?.[queryType2] as any[])[1][SCORE_FIELD] + expect((gqlResult2.data?.[queryType2] as any).edges[0][SCORE_FIELD]).toBeGreaterThanOrEqual( + (gqlResult2.data?.[queryType2] as any).edges[1][SCORE_FIELD] ); }); }); @@ -1945,27 +1968,8 @@ describe("@fulltext directive", () => { const indexName1 = "indexCreationName1"; const indexName2 = "indexCreationName2"; - const label = "someCustomLabel"; const aliasName = "someFieldAlias"; - const indexQueryCypher = ` - SHOW INDEXES yield - name AS name, - type AS type, - entityType AS entityType, - labelsOrTypes AS labelsOrTypes, - properties AS properties - WHERE name = "${indexName1}" OR name = "${indexName2}" - RETURN { - name: name, - type: type, - entityType: entityType, - labelsOrTypes: labelsOrTypes, - properties: properties - } as result - ORDER BY result.name ASC - `; - const deleteIndex1Cypher = ` DROP INDEX ${indexName1} IF EXISTS `; @@ -1995,8 +1999,8 @@ describe("@fulltext directive", () => { return; } - const typeDefs = gql` - type ${type.name} @node @fulltext(indexes: [{ indexName: "${indexName1}", fields: ["title"] }]) { + const typeDefs = /* GraphQL */ ` + type ${type.name} @node @fulltext(indexes: [{ indexName: "${indexName1}", queryName: "${type.plural}ByTitle", fields: ["title"] }]) { title: String! } `; @@ -2019,8 +2023,8 @@ describe("@fulltext directive", () => { return; } - const typeDefs = gql` - type ${type.name} @node @fulltext(indexes: [{ indexName: "${indexName1}", fields: ["title", "description"] }]) { + const typeDefs = /* GraphQL */ ` + type ${type.name} @node @fulltext(indexes: [{ indexName: "${indexName1}", queryName: "${type.plural}ByTitleAndDescription", fields: ["title", "description"] }]) { title: String! description: String! } @@ -2050,8 +2054,8 @@ describe("@fulltext directive", () => { return; } - const typeDefs = gql` - type ${type.name} @node @fulltext(indexes: [{ indexName: "${indexName1}", fields: ["title", "description"] }]) { + const typeDefs = /* GraphQL */ ` + type ${type.name} @node @fulltext(indexes: [{ indexName: "${indexName1}", queryName: "${type.plural}ByTitleAndDescription", fields: ["title", "description"] }]) { title: String! description: String! @alias(property: "${aliasName}") } @@ -2087,8 +2091,8 @@ describe("@fulltext directive", () => { const baseType = testHelper.createUniqueType("Base"); const additionalType = testHelper.createUniqueType("Additional"); - const typeDefs = ` - type ${baseType.name} @node(labels: ["${baseType.name}", "${additionalType.name}"]) @fulltext(indexes: [{ indexName: "${indexName1}", fields: ["title"] }]) { + const typeDefs = /* GraphQL */ ` + type ${baseType.name} @node(labels: ["${baseType.name}", "${additionalType.name}"]) @fulltext(indexes: [{ indexName: "${indexName1}", queryName: "${type.plural}ByTitle", fields: ["title"] }]) { title: String! } `; diff --git a/packages/graphql/tests/integration/issues/5030.int.test.ts b/packages/graphql/tests/integration/issues/5030.int.test.ts index a7da371231..63e2ade95c 100644 --- a/packages/graphql/tests/integration/issues/5030.int.test.ts +++ b/packages/graphql/tests/integration/issues/5030.int.test.ts @@ -29,7 +29,7 @@ describe("https://github.com/neo4j/graphql/issues/5030", () => { Movie = testHelper.createUniqueType("Movie"); const typeDefs = /* GraphQL */ ` - type ${Movie} @fulltext(indexes: [{ name: "MovieTitle", fields: ["title"] }]) @node { + type ${Movie} @fulltext(indexes: [{ indexName: "MovieTitle", queryName: "moviesByTitle", fields: ["title"] }]) @node { title: String released: Int } diff --git a/packages/graphql/tests/integration/issues/5378.int.test.ts b/packages/graphql/tests/integration/issues/5378.int.test.ts index 85f9dc977f..c6dbbf04a9 100644 --- a/packages/graphql/tests/integration/issues/5378.int.test.ts +++ b/packages/graphql/tests/integration/issues/5378.int.test.ts @@ -55,7 +55,7 @@ describe("https://github.com/neo4j/graphql/issues/5378", () => { const typeDefs = /* GraphQL */ ` type ${Space} @node - @fulltext(indexes: [{ indexName: "fulltext_index_space_name_number", fields: ["Name", "Number"] }]) { + @fulltext(indexes: [{ indexName: "fulltext_index_space_name_number", queryName: "spacesByNameAndNumber", fields: ["Name", "Number"] }]) { Id: ID! @id Number: String Name: String! @@ -96,11 +96,7 @@ describe("https://github.com/neo4j/graphql/issues/5378", () => { const query = /* GraphQL */ ` query SpacesSearchConnection { - ${Space.operations.connection}(fulltext: { - fulltext_index_space_name_number: { - phrase: "Bedroom" - } - }) { + spacesByNameAndNumber(phrase: "Bedroom") { totalCount edges { node { @@ -109,13 +105,6 @@ describe("https://github.com/neo4j/graphql/issues/5378", () => { } } } - ${Space.operations.aggregate}(fulltext: { - fulltext_index_space_name_number: { - phrase: "Bedroom" - } - }) { - count - } } `; @@ -128,7 +117,7 @@ describe("https://github.com/neo4j/graphql/issues/5378", () => { const gqlResult = await testHelper.executeGraphQL(query); expect(gqlResult.errors).toBeFalsy(); expect(gqlResult.data).toEqual({ - [Space.operations.connection]: { + spacesByNameAndNumber: { totalCount: 2, edges: expect.toIncludeSameMembers([ { @@ -145,9 +134,6 @@ describe("https://github.com/neo4j/graphql/issues/5378", () => { }, ]), }, - [Space.operations.aggregate]: { - count: 2, - }, }); }); }); diff --git a/packages/graphql/tests/schema/fulltext.test.ts b/packages/graphql/tests/schema/fulltext.test.ts index ef6bd3826a..7e5774a6ac 100644 --- a/packages/graphql/tests/schema/fulltext.test.ts +++ b/packages/graphql/tests/schema/fulltext.test.ts @@ -18,8 +18,8 @@ */ import { printSchemaWithDirectives } from "@graphql-tools/utils"; -import { lexicographicSortSchema } from "graphql/utilities"; import { gql } from "graphql-tag"; +import { lexicographicSortSchema } from "graphql/utilities"; import { Neo4jGraphQL } from "../../src"; describe("@fulltext schema", () => { @@ -29,8 +29,8 @@ describe("@fulltext schema", () => { @node @fulltext( indexes: [ - { name: "MovieTitle", fields: ["title"] } - { name: "MovieDescription", fields: ["description"] } + { indexName: "MovieTitle", queryName: "moviesByTitle", fields: ["title"] } + { indexName: "MovieDescription", queryName: "moviesByDescription", fields: ["description"] } ] ) { title: String @@ -95,37 +95,24 @@ describe("@fulltext schema", () => { node: Movie! } - input MovieFulltext { - MovieDescription: MovieMovieDescriptionFulltext - MovieTitle: MovieMovieTitleFulltext - } - - \\"\\"\\"The result of a fulltext search on an index of Movie\\"\\"\\" - type MovieFulltextResult { - movie: Movie! + type MovieIndexEdge { + cursor: String! + node: Movie! score: Float! } - \\"\\"\\"The input for sorting a fulltext query on an index of Movie\\"\\"\\" - input MovieFulltextSort { - movie: MovieSort + \\"\\"\\"The input for sorting a Fulltext query on an index of Movie\\"\\"\\" + input MovieIndexSort { + node: MovieSort score: SortDirection } - \\"\\"\\"The input for filtering a fulltext query on an index of Movie\\"\\"\\" - input MovieFulltextWhere { - movie: MovieWhere + \\"\\"\\"The input for filtering a full-text query on an index of Movie\\"\\"\\" + input MovieIndexWhere { + node: MovieWhere score: FloatWhere } - input MovieMovieDescriptionFulltext { - phrase: String! - } - - input MovieMovieTitleFulltext { - phrase: String! - } - input MovieOptions { limit: Int offset: Int @@ -172,6 +159,12 @@ describe("@fulltext schema", () => { totalCount: Int! } + type MoviesIndexConnection { + edges: [MovieIndexEdge!]! + pageInfo: PageInfo! + totalCount: Int! + } + type Mutation { createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! deleteMovies(where: MovieWhere): DeleteInfo! @@ -187,42 +180,11 @@ describe("@fulltext schema", () => { } type Query { - movies( - \\"\\"\\" - Query a full-text index. Allows for the aggregation of results, but does not return the query score. Use the root full-text query fields if you require the score. - \\"\\"\\" - fulltext: MovieFulltext - limit: Int - offset: Int - options: MovieOptions @deprecated(reason: \\"Query options argument is deprecated, please use pagination arguments like limit, offset and sort instead.\\") - sort: [MovieSort!] - where: MovieWhere - ): [Movie!]! - moviesAggregate( - \\"\\"\\" - Query a full-text index. Allows for the aggregation of results, but does not return the query score. Use the root full-text query fields if you require the score. - \\"\\"\\" - fulltext: MovieFulltext - where: MovieWhere - ): MovieAggregateSelection! - moviesConnection( - after: String - first: Int - \\"\\"\\" - Query a full-text index. Allows for the aggregation of results, but does not return the query score. Use the root full-text query fields if you require the score. - \\"\\"\\" - fulltext: MovieFulltext - sort: [MovieSort!] - where: MovieWhere - ): MoviesConnection! - \\"\\"\\" - Query a full-text index. This query returns the query score, but does not allow for aggregations. Use the \`fulltext\` argument under other queries for this functionality. - \\"\\"\\" - moviesFulltextMovieDescription(limit: Int, offset: Int, phrase: String!, sort: [MovieFulltextSort!], where: MovieFulltextWhere): [MovieFulltextResult!]! - \\"\\"\\" - Query a full-text index. This query returns the query score, but does not allow for aggregations. Use the \`fulltext\` argument under other queries for this functionality. - \\"\\"\\" - moviesFulltextMovieTitle(limit: Int, offset: Int, phrase: String!, sort: [MovieFulltextSort!], where: MovieFulltextWhere): [MovieFulltextResult!]! + movies(limit: Int, offset: Int, options: MovieOptions @deprecated(reason: \\"Query options argument is deprecated, please use pagination arguments like limit, offset and sort instead.\\"), sort: [MovieSort!], where: MovieWhere): [Movie!]! + moviesAggregate(where: MovieWhere): MovieAggregateSelection! + moviesByDescription(after: String, first: Int, phrase: String!, sort: [MovieIndexSort!], where: MovieIndexWhere): MoviesIndexConnection! + moviesByTitle(after: String, first: Int, phrase: String!, sort: [MovieIndexSort!], where: MovieIndexWhere): MoviesIndexConnection! + moviesConnection(after: String, first: Int, sort: [MovieSort!], where: MovieWhere): MoviesConnection! } \\"\\"\\"An enum for sorting in either ascending or descending order.\\"\\"\\" diff --git a/packages/graphql/tests/schema/vector.test.ts b/packages/graphql/tests/schema/vector.test.ts index cbd7ae6a50..14d6d70e3e 100644 --- a/packages/graphql/tests/schema/vector.test.ts +++ b/packages/graphql/tests/schema/vector.test.ts @@ -99,6 +99,24 @@ describe("@vector schema", () => { node: Movie! } + type MovieIndexEdge { + cursor: String! + node: Movie! + score: Float! + } + + \\"\\"\\"The input for sorting a Vector query on an index of Movie\\"\\"\\" + input MovieIndexSort { + node: MovieSort + score: SortDirection + } + + \\"\\"\\"The input for filtering a Vector query on an index of Movie\\"\\"\\" + input MovieIndexWhere { + node: MovieWhere + score: FloatWhere + } + input MovieOptions { limit: Int offset: Int @@ -121,24 +139,6 @@ describe("@vector schema", () => { title: String } - type MovieVectorEdge { - cursor: String! - node: Movie! - score: Float! - } - - \\"\\"\\"The input for sorting a Vector query on an index of Movie\\"\\"\\" - input MovieVectorSort { - node: MovieSort - score: SortDirection - } - - \\"\\"\\"The input for filtering a Vector query on an index of Movie\\"\\"\\" - input MovieVectorWhere { - node: MovieWhere - score: FloatWhere - } - input MovieWhere { AND: [MovieWhere!] NOT: MovieWhere @@ -163,8 +163,8 @@ describe("@vector schema", () => { totalCount: Int! } - type MoviesVectorConnection { - edges: [MovieVectorEdge!]! + type MoviesIndexConnection { + edges: [MovieIndexEdge!]! pageInfo: PageInfo! totalCount: Int! } @@ -184,11 +184,11 @@ describe("@vector schema", () => { } type Query { - descriptionQuery(after: String, first: Int, sort: [MovieVectorSort!], vector: [Float!], where: MovieVectorWhere): MoviesVectorConnection! + descriptionQuery(after: String, first: Int, sort: [MovieIndexSort!], vector: [Float!], where: MovieIndexWhere): MoviesIndexConnection! movies(limit: Int, offset: Int, options: MovieOptions @deprecated(reason: \\"Query options argument is deprecated, please use pagination arguments like limit, offset and sort instead.\\"), sort: [MovieSort!], where: MovieWhere): [Movie!]! moviesAggregate(where: MovieWhere): MovieAggregateSelection! moviesConnection(after: String, first: Int, sort: [MovieSort!], where: MovieWhere): MoviesConnection! - titleQuery(after: String, first: Int, sort: [MovieVectorSort!], vector: [Float!], where: MovieVectorWhere): MoviesVectorConnection! + titleQuery(after: String, first: Int, sort: [MovieIndexSort!], vector: [Float!], where: MovieIndexWhere): MoviesIndexConnection! } \\"\\"\\"An enum for sorting in either ascending or descending order.\\"\\"\\" diff --git a/packages/graphql/tests/tck/fulltext/aggregate.test.ts b/packages/graphql/tests/tck/fulltext/aggregate.test.ts deleted file mode 100644 index 7e8debb2e7..0000000000 --- a/packages/graphql/tests/tck/fulltext/aggregate.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * 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 { Neo4jGraphQL } from "../../../src"; -import { formatCypher, formatParams, translateQuery } from "../utils/tck-test-utils"; - -describe("Cypher -> fulltext -> Aggregate", () => { - let typeDefs: string; - let neoSchema: Neo4jGraphQL; - - beforeAll(() => { - typeDefs = /* GraphQL */ ` - type Movie @fulltext(indexes: [{ name: "MovieTitle", fields: ["title"] }]) @node { - title: String - } - `; - - neoSchema = new Neo4jGraphQL({ - typeDefs, - }); - }); - - test("simple aggregate with single fulltext property", async () => { - const query = /* GraphQL */ ` - query { - moviesAggregate(fulltext: { MovieTitle: { phrase: "something AND something" } }) { - count - } - } - `; - - const result = await translateQuery(neoSchema, query, {}); - - expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - "CALL { - CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 - WHERE $param1 IN labels(this0) - RETURN count(this0) AS var2 - } - RETURN { count: var2 }" - `); - - expect(formatParams(result.params)).toMatchInlineSnapshot(` - "{ - \\"param0\\": \\"something AND something\\", - \\"param1\\": \\"Movie\\" - }" - `); - }); -}); diff --git a/packages/graphql/tests/tck/fulltext/auth.test.ts b/packages/graphql/tests/tck/fulltext/auth.test.ts index d04be1422e..7763d824ad 100644 --- a/packages/graphql/tests/tck/fulltext/auth.test.ts +++ b/packages/graphql/tests/tck/fulltext/auth.test.ts @@ -37,111 +37,64 @@ describe("Cypher -> fulltext -> Auth", () => { } }); - describe("4.4", () => { - test("simple match with auth where", async () => { - const typeDefs = /* GraphQL */ ` - type Movie - @node - @fulltext(indexes: [{ name: "MovieTitle", fields: ["title"] }]) - @authorization(filter: [{ where: { node: { director_SOME: { id_EQ: "$jwt.sub" } } } }]) { - title: String - director: [Person!]! @relationship(type: "DIRECTED", direction: IN) - } - - type Person @node { - id: ID - } - `; - - const secret = "shh-its-a-secret"; - - const sub = "my-sub"; - - const neoSchema = new Neo4jGraphQL({ - typeDefs, - features: { authorization: { key: secret } }, - }); - - const query = /* GraphQL */ ` - query { - movies(fulltext: { MovieTitle: { phrase: "something AND something" } }) { - title - } - } - `; - - const token = createBearerToken(secret, { sub }); - - const result = await translateQuery(neoSchema, query, { token, neo4jVersion: "4.4" }); - - expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 - WHERE $param1 IN labels(this0) - WITH * - WHERE ($isAuthenticated = true AND size([(this0)<-[:DIRECTED]-(this2:Person) WHERE ($jwt.sub IS NOT NULL AND this2.id = $jwt.sub) | 1]) > 0) - RETURN this0 { .title } AS this" - `); - - expect(result.params).toMatchInlineSnapshot(` - Object { - "isAuthenticated": true, - "jwt": Object { - "roles": Array [], - "sub": "my-sub", - }, - "param0": "something AND something", - "param1": "Movie", - } - `); + test("simple match with auth where", async () => { + const typeDefs = /* GraphQL */ ` + type Movie + @node + @fulltext(indexes: [{ indexName: "MovieTitle", queryName: "moviesByTitle", fields: ["title"] }]) + @authorization(filter: [{ where: { node: { director_SOME: { id_EQ: "$jwt.sub" } } } }]) { + title: String + director: [Person!]! @relationship(type: "DIRECTED", direction: IN) + } + + type Person @node { + id: ID + } + `; + + const secret = "shh-its-a-secret"; + + const sub = "my-sub"; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + features: { authorization: { key: secret } }, }); - test("simple match with auth allow", async () => { - const typeDefs = /* GraphQL */ ` - type Movie - @node - @fulltext(indexes: [{ name: "MovieTitle", fields: ["title"] }]) - @authorization( - validate: [{ when: [BEFORE], where: { node: { director_SOME: { id_EQ: "$jwt.sub" } } } }] - ) { - title: String - director: [Person!]! @relationship(type: "DIRECTED", direction: IN) - } - - type Person @node { - id: ID - } - `; - - const secret = "shh-its-a-secret"; - - const sub = "my-sub"; - - const neoSchema = new Neo4jGraphQL({ - typeDefs, - features: { authorization: { key: secret } }, - }); - - const query = /* GraphQL */ ` - query { - movies(fulltext: { MovieTitle: { phrase: "something AND something" } }) { - title + const query = /* GraphQL */ ` + query { + moviesByTitle(phrase: "something AND something") { + edges { + node { + title + } } } - `; - - const token = createBearerToken(secret, { sub }); - - const result = await translateQuery(neoSchema, query, { token, neo4jVersion: "4.4" }); - - expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 - WHERE $param1 IN labels(this0) - WITH * - WHERE apoc.util.validatePredicate(NOT ($isAuthenticated = true AND size([(this0)<-[:DIRECTED]-(this2:Person) WHERE ($jwt.sub IS NOT NULL AND this2.id = $jwt.sub) | 1]) > 0), \\"@neo4j/graphql/FORBIDDEN\\", [0]) - RETURN this0 { .title } AS this" - `); - - expect(result.params).toMatchInlineSnapshot(` + } + `; + + const token = createBearerToken(secret, { sub }); + + const result = await translateQuery(neoSchema, query, { token, neo4jVersion: "5" }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 + WHERE ($param1 IN labels(this0) AND ($isAuthenticated = true AND EXISTS { + MATCH (this0)<-[:DIRECTED]-(this2:Person) + WHERE ($jwt.sub IS NOT NULL AND this2.id = $jwt.sub) + })) + WITH collect({ node: this0 }) AS edges + WITH edges, size(edges) AS totalCount + CALL { + WITH edges + UNWIND edges AS edge + WITH edge.node AS this0 + RETURN collect({ node: { title: this0.title, __resolveType: \\"Movie\\" } }) AS var3 + } + RETURN { edges: var3, totalCount: totalCount } AS this" + `); + + expect(result.params).toMatchInlineSnapshot(` Object { "isAuthenticated": true, "jwt": Object { @@ -152,119 +105,68 @@ describe("Cypher -> fulltext -> Auth", () => { "param1": "Movie", } `); - }); - - test("simple match with auth allow ALL", async () => { - const typeDefs = /* GraphQL */ ` - type Movie - @node - @fulltext(indexes: [{ name: "MovieTitle", fields: ["title"] }]) - @authorization( - validate: [{ when: [BEFORE], where: { node: { director_ALL: { id_EQ: "$jwt.sub" } } } }] - ) { - title: String - director: [Person!]! @relationship(type: "DIRECTED", direction: IN) - } - - type Person @node { - id: ID - } - `; - - const secret = "shh-its-a-secret"; - - const sub = "my-sub"; - - const neoSchema = new Neo4jGraphQL({ - typeDefs, - features: { authorization: { key: secret } }, - }); - - const query = /* GraphQL */ ` - query { - movies(fulltext: { MovieTitle: { phrase: "something AND something" } }) { - title - } - } - `; - - const token = createBearerToken(secret, { sub }); - - const result = await translateQuery(neoSchema, query, { token, neo4jVersion: "4.4" }); - - expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 - WHERE $param1 IN labels(this0) - WITH * - WHERE apoc.util.validatePredicate(NOT ($isAuthenticated = true AND size([(this0)<-[:DIRECTED]-(this2:Person) WHERE NOT ($jwt.sub IS NOT NULL AND this2.id = $jwt.sub) | 1]) = 0), \\"@neo4j/graphql/FORBIDDEN\\", [0]) - RETURN this0 { .title } AS this" - `); + }); - expect(result.params).toMatchInlineSnapshot(` - Object { - "isAuthenticated": true, - "jwt": Object { - "roles": Array [], - "sub": "my-sub", - }, - "param0": "something AND something", - "param1": "Movie", - } - `); + test("simple match with auth allow", async () => { + const typeDefs = /* GraphQL */ ` + type Movie + @node + @fulltext(indexes: [{ indexName: "MovieTitle", queryName: "moviesByTitle", fields: ["title"] }]) + @authorization( + validate: [{ when: [BEFORE], where: { node: { director_SOME: { id_EQ: "$jwt.sub" } } } }] + ) { + title: String + director: [Person!]! @relationship(type: "DIRECTED", direction: IN) + } + + type Person @node { + id: ID + } + `; + + const secret = "shh-its-a-secret"; + + const sub = "my-sub"; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + features: { authorization: { key: secret } }, }); - test("simple match with auth allow on connection node", async () => { - const typeDefs = /* GraphQL */ ` - type Movie - @node - @fulltext(indexes: [{ name: "MovieTitle", fields: ["title"] }]) - @authorization( - validate: [ - { - when: [BEFORE] - where: { node: { directorConnection_SOME: { node: { id_EQ: "$jwt.sub" } } } } - } - ] - ) { - title: String - director: [Person!]! @relationship(type: "DIRECTED", direction: IN) - } - - type Person @node { - id: ID - } - `; - - const secret = "shh-its-a-secret"; - - const sub = "my-sub"; - - const neoSchema = new Neo4jGraphQL({ - typeDefs, - features: { authorization: { key: secret } }, - }); - - const query = /* GraphQL */ ` - query { - movies(fulltext: { MovieTitle: { phrase: "something AND something" } }) { - title + const query = /* GraphQL */ ` + query { + moviesByTitle(phrase: "something AND something") { + edges { + node { + title + } } } - `; - - const token = createBearerToken(secret, { sub }); - - const result = await translateQuery(neoSchema, query, { token, neo4jVersion: "4.4" }); - - expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 - WHERE $param1 IN labels(this0) - WITH * - WHERE apoc.util.validatePredicate(NOT ($isAuthenticated = true AND size([(this0)<-[this3:DIRECTED]-(this2:Person) WHERE ($jwt.sub IS NOT NULL AND this2.id = $jwt.sub) | 1]) > 0), \\"@neo4j/graphql/FORBIDDEN\\", [0]) - RETURN this0 { .title } AS this" - `); - - expect(result.params).toMatchInlineSnapshot(` + } + `; + + const token = createBearerToken(secret, { sub }); + + const result = await translateQuery(neoSchema, query, { token, neo4jVersion: "5" }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 + WHERE ($param1 IN labels(this0) AND apoc.util.validatePredicate(NOT ($isAuthenticated = true AND EXISTS { + MATCH (this0)<-[:DIRECTED]-(this2:Person) + WHERE ($jwt.sub IS NOT NULL AND this2.id = $jwt.sub) + }), \\"@neo4j/graphql/FORBIDDEN\\", [0])) + WITH collect({ node: this0 }) AS edges + WITH edges, size(edges) AS totalCount + CALL { + WITH edges + UNWIND edges AS edge + WITH edge.node AS this0 + RETURN collect({ node: { title: this0.title, __resolveType: \\"Movie\\" } }) AS var3 + } + RETURN { edges: var3, totalCount: totalCount } AS this" + `); + + expect(result.params).toMatchInlineSnapshot(` Object { "isAuthenticated": true, "jwt": Object { @@ -275,60 +177,71 @@ describe("Cypher -> fulltext -> Auth", () => { "param1": "Movie", } `); - }); - - test("simple match with auth allow on connection node ALL", async () => { - const typeDefs = /* GraphQL */ ` - type Movie - @node - @fulltext(indexes: [{ name: "MovieTitle", fields: ["title"] }]) - @authorization( - validate: [ - { - when: [BEFORE] - where: { node: { directorConnection_ALL: { node: { id_EQ: "$jwt.sub" } } } } - } - ] - ) { - title: String - director: [Person!]! @relationship(type: "DIRECTED", direction: IN) - } - - type Person @node { - id: ID - } - `; - - const secret = "shh-its-a-secret"; - - const sub = "my-sub"; + }); - const neoSchema = new Neo4jGraphQL({ - typeDefs, - features: { authorization: { key: secret } }, - }); + test("simple match with auth allow ALL", async () => { + const typeDefs = /* GraphQL */ ` + type Movie + @node + @fulltext(indexes: [{ indexName: "MovieTitle", queryName: "moviesByTitle", fields: ["title"] }]) + @authorization( + validate: [{ when: [BEFORE], where: { node: { director_ALL: { id_EQ: "$jwt.sub" } } } }] + ) { + title: String + director: [Person!]! @relationship(type: "DIRECTED", direction: IN) + } + + type Person @node { + id: ID + } + `; + + const secret = "shh-its-a-secret"; + + const sub = "my-sub"; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + features: { authorization: { key: secret } }, + }); - const query = /* GraphQL */ ` - query { - movies(fulltext: { MovieTitle: { phrase: "something AND something" } }) { - title + const query = /* GraphQL */ ` + query { + moviesByTitle(phrase: "something AND something") { + edges { + node { + title + } } } - `; - - const token = createBearerToken(secret, { sub }); - - const result = await translateQuery(neoSchema, query, { token, neo4jVersion: "4.4" }); - - expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 - WHERE $param1 IN labels(this0) - WITH * - WHERE apoc.util.validatePredicate(NOT ($isAuthenticated = true AND size([(this0)<-[this3:DIRECTED]-(this2:Person) WHERE NOT ($jwt.sub IS NOT NULL AND this2.id = $jwt.sub) | 1]) = 0), \\"@neo4j/graphql/FORBIDDEN\\", [0]) - RETURN this0 { .title } AS this" - `); - - expect(result.params).toMatchInlineSnapshot(` + } + `; + + const token = createBearerToken(secret, { sub }); + + const result = await translateQuery(neoSchema, query, { token, neo4jVersion: "5" }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 + WHERE ($param1 IN labels(this0) AND apoc.util.validatePredicate(NOT ($isAuthenticated = true AND (EXISTS { + MATCH (this0)<-[:DIRECTED]-(this2:Person) + WHERE ($jwt.sub IS NOT NULL AND this2.id = $jwt.sub) + } AND NOT (EXISTS { + MATCH (this0)<-[:DIRECTED]-(this2:Person) + WHERE NOT ($jwt.sub IS NOT NULL AND this2.id = $jwt.sub) + }))), \\"@neo4j/graphql/FORBIDDEN\\", [0])) + WITH collect({ node: this0 }) AS edges + WITH edges, size(edges) AS totalCount + CALL { + WITH edges + UNWIND edges AS edge + WITH edge.node AS this0 + RETURN collect({ node: { title: this0.title, __resolveType: \\"Movie\\" } }) AS var3 + } + RETURN { edges: var3, totalCount: totalCount } AS this" + `); + + expect(result.params).toMatchInlineSnapshot(` Object { "isAuthenticated": true, "jwt": Object { @@ -339,247 +252,73 @@ describe("Cypher -> fulltext -> Auth", () => { "param1": "Movie", } `); - }); - - test("simple match with auth allow on connection edge", async () => { - const typeDefs = /* GraphQL */ ` - type Movie - @node - @fulltext(indexes: [{ name: "MovieTitle", fields: ["title"] }]) - @authorization( - validate: [ - { - when: [BEFORE] - where: { node: { directorConnection_SOME: { edge: { year_EQ: 2020 } } } } - } - ] - ) { - title: String - director: [Person!]! @relationship(type: "DIRECTED", direction: IN, properties: "Directed") - } - - type Person @node { - id: ID - } - - type Directed @relationshipProperties { - year: Int - } - `; - - const secret = "shh-its-a-secret"; - - const sub = "my-sub"; - - const neoSchema = new Neo4jGraphQL({ - typeDefs, - features: { authorization: { key: secret } }, - }); - - const query = /* GraphQL */ ` - query { - movies(fulltext: { MovieTitle: { phrase: "something AND something" } }) { - title - } - } - `; - - const token = createBearerToken(secret, { sub }); - - const result = await translateQuery(neoSchema, query, { token, neo4jVersion: "4.4" }); - - expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 - WHERE $param1 IN labels(this0) - WITH * - WHERE apoc.util.validatePredicate(NOT ($isAuthenticated = true AND size([(this0)<-[this2:DIRECTED]-(this3:Person) WHERE ($param3 IS NOT NULL AND this2.year = $param3) | 1]) > 0), \\"@neo4j/graphql/FORBIDDEN\\", [0]) - RETURN this0 { .title } AS this" - `); - - expect(result.params).toMatchInlineSnapshot(` - Object { - "isAuthenticated": true, - "param0": "something AND something", - "param1": "Movie", - "param3": 2020, - } - `); - }); - - test("simple match with auth allow on connection edge ALL", async () => { - const typeDefs = /* GraphQL */ ` - type Movie - @node - @fulltext(indexes: [{ name: "MovieTitle", fields: ["title"] }]) - @authorization( - validate: [ - { when: [BEFORE], where: { node: { directorConnection_ALL: { edge: { year_EQ: 2020 } } } } } - ] - ) { - title: String - director: [Person!]! @relationship(type: "DIRECTED", direction: IN, properties: "Directed") - } - - type Person @node { - id: ID - } - - type Directed @relationshipProperties { - year: Int - } - `; - - const secret = "shh-its-a-secret"; - - const sub = "my-sub"; - - const neoSchema = new Neo4jGraphQL({ - typeDefs, - features: { authorization: { key: secret } }, - }); - - const query = /* GraphQL */ ` - query { - movies(fulltext: { MovieTitle: { phrase: "something AND something" } }) { - title - } - } - `; - - const token = createBearerToken(secret, { sub }); - - const result = await translateQuery(neoSchema, query, { token, neo4jVersion: "4.4" }); - - expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 - WHERE $param1 IN labels(this0) - WITH * - WHERE apoc.util.validatePredicate(NOT ($isAuthenticated = true AND size([(this0)<-[this2:DIRECTED]-(this3:Person) WHERE NOT ($param3 IS NOT NULL AND this2.year = $param3) | 1]) = 0), \\"@neo4j/graphql/FORBIDDEN\\", [0]) - RETURN this0 { .title } AS this" - `); - - expect(result.params).toMatchInlineSnapshot(` - Object { - "isAuthenticated": true, - "param0": "something AND something", - "param1": "Movie", - "param3": 2020, - } - `); - }); }); - describe("5", () => { - test("simple match with auth where", async () => { - const typeDefs = /* GraphQL */ ` - type Movie - @node - @fulltext(indexes: [{ name: "MovieTitle", fields: ["title"] }]) - @authorization(filter: [{ where: { node: { director_SOME: { id_EQ: "$jwt.sub" } } } }]) { - title: String - director: [Person!]! @relationship(type: "DIRECTED", direction: IN) - } - - type Person @node { - id: ID - } - `; - - const secret = "shh-its-a-secret"; - - const sub = "my-sub"; - - const neoSchema = new Neo4jGraphQL({ - typeDefs, - features: { authorization: { key: secret } }, - }); - - const query = /* GraphQL */ ` - query { - movies(fulltext: { MovieTitle: { phrase: "something AND something" } }) { - title - } - } - `; - - const token = createBearerToken(secret, { sub }); - - const result = await translateQuery(neoSchema, query, { token, neo4jVersion: "5" }); - - expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 - WHERE $param1 IN labels(this0) - WITH * - WHERE ($isAuthenticated = true AND EXISTS { - MATCH (this0)<-[:DIRECTED]-(this2:Person) - WHERE ($jwt.sub IS NOT NULL AND this2.id = $jwt.sub) - }) - RETURN this0 { .title } AS this" - `); - - expect(result.params).toMatchInlineSnapshot(` - Object { - "isAuthenticated": true, - "jwt": Object { - "roles": Array [], - "sub": "my-sub", - }, - "param0": "something AND something", - "param1": "Movie", - } - `); + test("simple match with auth allow on connection node", async () => { + const typeDefs = /* GraphQL */ ` + type Movie + @node + @fulltext(indexes: [{ indexName: "MovieTitle", queryName: "moviesByTitle", fields: ["title"] }]) + @authorization( + validate: [ + { + when: [BEFORE] + where: { node: { directorConnection_SOME: { node: { id_EQ: "$jwt.sub" } } } } + } + ] + ) { + title: String + director: [Person!]! @relationship(type: "DIRECTED", direction: IN) + } + + type Person @node { + id: ID + } + `; + + const secret = "shh-its-a-secret"; + + const sub = "my-sub"; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + features: { authorization: { key: secret } }, }); - test("simple match with auth allow", async () => { - const typeDefs = /* GraphQL */ ` - type Movie - @node - @fulltext(indexes: [{ name: "MovieTitle", fields: ["title"] }]) - @authorization( - validate: [{ when: [BEFORE], where: { node: { director_SOME: { id_EQ: "$jwt.sub" } } } }] - ) { - title: String - director: [Person!]! @relationship(type: "DIRECTED", direction: IN) - } - - type Person @node { - id: ID - } - `; - - const secret = "shh-its-a-secret"; - - const sub = "my-sub"; - - const neoSchema = new Neo4jGraphQL({ - typeDefs, - features: { authorization: { key: secret } }, - }); - - const query = /* GraphQL */ ` - query { - movies(fulltext: { MovieTitle: { phrase: "something AND something" } }) { - title + const query = /* GraphQL */ ` + query { + moviesByTitle(phrase: "something AND something") { + edges { + node { + title + } } } - `; - - const token = createBearerToken(secret, { sub }); - - const result = await translateQuery(neoSchema, query, { token, neo4jVersion: "5" }); - - expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 - WHERE $param1 IN labels(this0) - WITH * - WHERE apoc.util.validatePredicate(NOT ($isAuthenticated = true AND EXISTS { - MATCH (this0)<-[:DIRECTED]-(this2:Person) - WHERE ($jwt.sub IS NOT NULL AND this2.id = $jwt.sub) - }), \\"@neo4j/graphql/FORBIDDEN\\", [0]) - RETURN this0 { .title } AS this" - `); - - expect(result.params).toMatchInlineSnapshot(` + } + `; + + const token = createBearerToken(secret, { sub }); + + const result = await translateQuery(neoSchema, query, { token, neo4jVersion: "5" }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 + WHERE ($param1 IN labels(this0) AND apoc.util.validatePredicate(NOT ($isAuthenticated = true AND EXISTS { + MATCH (this0)<-[this2:DIRECTED]-(this3:Person) + WHERE ($jwt.sub IS NOT NULL AND this3.id = $jwt.sub) + }), \\"@neo4j/graphql/FORBIDDEN\\", [0])) + WITH collect({ node: this0 }) AS edges + WITH edges, size(edges) AS totalCount + CALL { + WITH edges + UNWIND edges AS edge + WITH edge.node AS this0 + RETURN collect({ node: { title: this0.title, __resolveType: \\"Movie\\" } }) AS var4 + } + RETURN { edges: var4, totalCount: totalCount } AS this" + `); + + expect(result.params).toMatchInlineSnapshot(` Object { "isAuthenticated": true, "jwt": Object { @@ -590,128 +329,73 @@ describe("Cypher -> fulltext -> Auth", () => { "param1": "Movie", } `); - }); - - test("simple match with auth allow ALL", async () => { - const typeDefs = /* GraphQL */ ` - type Movie - @node - @fulltext(indexes: [{ name: "MovieTitle", fields: ["title"] }]) - @authorization( - validate: [{ when: [BEFORE], where: { node: { director_ALL: { id_EQ: "$jwt.sub" } } } }] - ) { - title: String - director: [Person!]! @relationship(type: "DIRECTED", direction: IN) - } - - type Person @node { - id: ID - } - `; - - const secret = "shh-its-a-secret"; - - const sub = "my-sub"; - - const neoSchema = new Neo4jGraphQL({ - typeDefs, - features: { authorization: { key: secret } }, - }); - - const query = /* GraphQL */ ` - query { - movies(fulltext: { MovieTitle: { phrase: "something AND something" } }) { - title - } - } - `; - - const token = createBearerToken(secret, { sub }); - - const result = await translateQuery(neoSchema, query, { token, neo4jVersion: "5" }); - - expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 - WHERE $param1 IN labels(this0) - WITH * - WHERE apoc.util.validatePredicate(NOT ($isAuthenticated = true AND (EXISTS { - MATCH (this0)<-[:DIRECTED]-(this2:Person) - WHERE ($jwt.sub IS NOT NULL AND this2.id = $jwt.sub) - } AND NOT (EXISTS { - MATCH (this0)<-[:DIRECTED]-(this2:Person) - WHERE NOT ($jwt.sub IS NOT NULL AND this2.id = $jwt.sub) - }))), \\"@neo4j/graphql/FORBIDDEN\\", [0]) - RETURN this0 { .title } AS this" - `); + }); - expect(result.params).toMatchInlineSnapshot(` - Object { - "isAuthenticated": true, - "jwt": Object { - "roles": Array [], - "sub": "my-sub", - }, - "param0": "something AND something", - "param1": "Movie", - } - `); + test("simple match with auth allow on connection node ALL", async () => { + const typeDefs = /* GraphQL */ ` + type Movie + @node + @fulltext(indexes: [{ indexName: "MovieTitle", queryName: "moviesByTitle", fields: ["title"] }]) + @authorization( + validate: [ + { when: [BEFORE], where: { node: { directorConnection_ALL: { node: { id_EQ: "$jwt.sub" } } } } } + ] + ) { + title: String + director: [Person!]! @relationship(type: "DIRECTED", direction: IN) + } + + type Person @node { + id: ID + } + `; + + const secret = "shh-its-a-secret"; + + const sub = "my-sub"; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + features: { authorization: { key: secret } }, }); - test("simple match with auth allow on connection node", async () => { - const typeDefs = /* GraphQL */ ` - type Movie - @node - @fulltext(indexes: [{ name: "MovieTitle", fields: ["title"] }]) - @authorization( - validate: [ - { - when: [BEFORE] - where: { node: { directorConnection_SOME: { node: { id_EQ: "$jwt.sub" } } } } - } - ] - ) { - title: String - director: [Person!]! @relationship(type: "DIRECTED", direction: IN) - } - - type Person @node { - id: ID - } - `; - - const secret = "shh-its-a-secret"; - - const sub = "my-sub"; - - const neoSchema = new Neo4jGraphQL({ - typeDefs, - features: { authorization: { key: secret } }, - }); - - const query = /* GraphQL */ ` - query { - movies(fulltext: { MovieTitle: { phrase: "something AND something" } }) { - title + const query = /* GraphQL */ ` + query { + moviesByTitle(phrase: "something AND something") { + edges { + node { + title + } } } - `; - - const token = createBearerToken(secret, { sub }); - - const result = await translateQuery(neoSchema, query, { token, neo4jVersion: "5" }); - - expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 - WHERE $param1 IN labels(this0) - WITH * - WHERE apoc.util.validatePredicate(NOT ($isAuthenticated = true AND EXISTS { - MATCH (this0)<-[this2:DIRECTED]-(this3:Person) - WHERE ($jwt.sub IS NOT NULL AND this3.id = $jwt.sub) - }), \\"@neo4j/graphql/FORBIDDEN\\", [0]) - RETURN this0 { .title } AS this" - `); - - expect(result.params).toMatchInlineSnapshot(` + } + `; + + const token = createBearerToken(secret, { sub }); + + const result = await translateQuery(neoSchema, query, { token, neo4jVersion: "5" }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 + WHERE ($param1 IN labels(this0) AND apoc.util.validatePredicate(NOT ($isAuthenticated = true AND (EXISTS { + MATCH (this0)<-[this2:DIRECTED]-(this3:Person) + WHERE ($jwt.sub IS NOT NULL AND this3.id = $jwt.sub) + } AND NOT (EXISTS { + MATCH (this0)<-[this2:DIRECTED]-(this3:Person) + WHERE NOT ($jwt.sub IS NOT NULL AND this3.id = $jwt.sub) + }))), \\"@neo4j/graphql/FORBIDDEN\\", [0])) + WITH collect({ node: this0 }) AS edges + WITH edges, size(edges) AS totalCount + CALL { + WITH edges + UNWIND edges AS edge + WITH edge.node AS this0 + RETURN collect({ node: { title: this0.title, __resolveType: \\"Movie\\" } }) AS var4 + } + RETURN { edges: var4, totalCount: totalCount } AS this" + `); + + expect(result.params).toMatchInlineSnapshot(` Object { "isAuthenticated": true, "jwt": Object { @@ -722,137 +406,74 @@ describe("Cypher -> fulltext -> Auth", () => { "param1": "Movie", } `); - }); - - test("simple match with auth allow on connection node ALL", async () => { - const typeDefs = /* GraphQL */ ` - type Movie - @node - @fulltext(indexes: [{ name: "MovieTitle", fields: ["title"] }]) - @authorization( - validate: [ - { - when: [BEFORE] - where: { node: { directorConnection_ALL: { node: { id_EQ: "$jwt.sub" } } } } - } - ] - ) { - title: String - director: [Person!]! @relationship(type: "DIRECTED", direction: IN) - } - - type Person @node { - id: ID - } - `; - - const secret = "shh-its-a-secret"; - - const sub = "my-sub"; - - const neoSchema = new Neo4jGraphQL({ - typeDefs, - features: { authorization: { key: secret } }, - }); - - const query = /* GraphQL */ ` - query { - movies(fulltext: { MovieTitle: { phrase: "something AND something" } }) { - title - } - } - `; - - const token = createBearerToken(secret, { sub }); - - const result = await translateQuery(neoSchema, query, { token, neo4jVersion: "5" }); - - expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 - WHERE $param1 IN labels(this0) - WITH * - WHERE apoc.util.validatePredicate(NOT ($isAuthenticated = true AND (EXISTS { - MATCH (this0)<-[this2:DIRECTED]-(this3:Person) - WHERE ($jwt.sub IS NOT NULL AND this3.id = $jwt.sub) - } AND NOT (EXISTS { - MATCH (this0)<-[this2:DIRECTED]-(this3:Person) - WHERE NOT ($jwt.sub IS NOT NULL AND this3.id = $jwt.sub) - }))), \\"@neo4j/graphql/FORBIDDEN\\", [0]) - RETURN this0 { .title } AS this" - `); + }); - expect(result.params).toMatchInlineSnapshot(` - Object { - "isAuthenticated": true, - "jwt": Object { - "roles": Array [], - "sub": "my-sub", - }, - "param0": "something AND something", - "param1": "Movie", - } - `); + test("simple match with auth allow on connection edge", async () => { + const typeDefs = /* GraphQL */ ` + type Movie + @node + @fulltext(indexes: [{ indexName: "MovieTitle", queryName: "moviesByTitle", fields: ["title"] }]) + @authorization( + validate: [ + { when: [BEFORE], where: { node: { directorConnection_SOME: { edge: { year_EQ: 2020 } } } } } + ] + ) { + title: String + director: [Person!]! @relationship(type: "DIRECTED", direction: IN, properties: "Directed") + } + + type Person @node { + id: ID + } + + type Directed @relationshipProperties { + year: Int + } + `; + + const secret = "shh-its-a-secret"; + + const sub = "my-sub"; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + features: { authorization: { key: secret } }, }); - test("simple match with auth allow on connection edge", async () => { - const typeDefs = /* GraphQL */ ` - type Movie - @node - @fulltext(indexes: [{ name: "MovieTitle", fields: ["title"] }]) - @authorization( - validate: [ - { - when: [BEFORE] - where: { node: { directorConnection_SOME: { edge: { year_EQ: 2020 } } } } - } - ] - ) { - title: String - director: [Person!]! @relationship(type: "DIRECTED", direction: IN, properties: "Directed") - } - - type Person @node { - id: ID - } - - type Directed @relationshipProperties { - year: Int - } - `; - - const secret = "shh-its-a-secret"; - - const sub = "my-sub"; - - const neoSchema = new Neo4jGraphQL({ - typeDefs, - features: { authorization: { key: secret } }, - }); - - const query = /* GraphQL */ ` - query { - movies(fulltext: { MovieTitle: { phrase: "something AND something" } }) { - title + const query = /* GraphQL */ ` + query { + moviesByTitle(phrase: "something AND something") { + edges { + node { + title + } } } - `; - - const token = createBearerToken(secret, { sub }); - - const result = await translateQuery(neoSchema, query, { token, neo4jVersion: "5" }); - - expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 - WHERE $param1 IN labels(this0) - WITH * - WHERE apoc.util.validatePredicate(NOT ($isAuthenticated = true AND EXISTS { - MATCH (this0)<-[this2:DIRECTED]-(this3:Person) - WHERE ($param3 IS NOT NULL AND this2.year = $param3) - }), \\"@neo4j/graphql/FORBIDDEN\\", [0]) - RETURN this0 { .title } AS this" - `); - - expect(result.params).toMatchInlineSnapshot(` + } + `; + + const token = createBearerToken(secret, { sub }); + + const result = await translateQuery(neoSchema, query, { token, neo4jVersion: "5" }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 + WHERE ($param1 IN labels(this0) AND apoc.util.validatePredicate(NOT ($isAuthenticated = true AND EXISTS { + MATCH (this0)<-[this2:DIRECTED]-(this3:Person) + WHERE ($param3 IS NOT NULL AND this2.year = $param3) + }), \\"@neo4j/graphql/FORBIDDEN\\", [0])) + WITH collect({ node: this0 }) AS edges + WITH edges, size(edges) AS totalCount + CALL { + WITH edges + UNWIND edges AS edge + WITH edge.node AS this0 + RETURN collect({ node: { title: this0.title, __resolveType: \\"Movie\\" } }) AS var4 + } + RETURN { edges: var4, totalCount: totalCount } AS this" + `); + + expect(result.params).toMatchInlineSnapshot(` Object { "isAuthenticated": true, "param0": "something AND something", @@ -860,67 +481,77 @@ describe("Cypher -> fulltext -> Auth", () => { "param3": 2020, } `); - }); - - test("simple match with auth allow on connection edge ALL", async () => { - const typeDefs = /* GraphQL */ ` - type Movie - @node - @fulltext(indexes: [{ name: "MovieTitle", fields: ["title"] }]) - @authorization( - validate: [ - { when: [BEFORE], where: { node: { directorConnection_ALL: { edge: { year_EQ: 2020 } } } } } - ] - ) { - title: String - director: [Person!]! @relationship(type: "DIRECTED", direction: IN, properties: "Directed") - } - - type Person @node { - id: ID - } - - type Directed @relationshipProperties { - year: Int - } - `; - - const secret = "shh-its-a-secret"; - - const sub = "my-sub"; + }); - const neoSchema = new Neo4jGraphQL({ - typeDefs, - features: { authorization: { key: secret } }, - }); + test("simple match with auth allow on connection edge ALL", async () => { + const typeDefs = /* GraphQL */ ` + type Movie + @node + @fulltext(indexes: [{ indexName: "MovieTitle", queryName: "moviesByTitle", fields: ["title"] }]) + @authorization( + validate: [ + { when: [BEFORE], where: { node: { directorConnection_ALL: { edge: { year_EQ: 2020 } } } } } + ] + ) { + title: String + director: [Person!]! @relationship(type: "DIRECTED", direction: IN, properties: "Directed") + } + + type Person @node { + id: ID + } + + type Directed @relationshipProperties { + year: Int + } + `; + + const secret = "shh-its-a-secret"; + + const sub = "my-sub"; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + features: { authorization: { key: secret } }, + }); - const query = /* GraphQL */ ` - query { - movies(fulltext: { MovieTitle: { phrase: "something AND something" } }) { - title + const query = /* GraphQL */ ` + query { + moviesByTitle(phrase: "something AND something") { + edges { + node { + title + } } } - `; - - const token = createBearerToken(secret, { sub }); - - const result = await translateQuery(neoSchema, query, { token, neo4jVersion: "5" }); - - expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` - "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 - WHERE $param1 IN labels(this0) - WITH * - WHERE apoc.util.validatePredicate(NOT ($isAuthenticated = true AND (EXISTS { - MATCH (this0)<-[this2:DIRECTED]-(this3:Person) - WHERE ($param3 IS NOT NULL AND this2.year = $param3) - } AND NOT (EXISTS { - MATCH (this0)<-[this2:DIRECTED]-(this3:Person) - WHERE NOT ($param3 IS NOT NULL AND this2.year = $param3) - }))), \\"@neo4j/graphql/FORBIDDEN\\", [0]) - RETURN this0 { .title } AS this" - `); - - expect(result.params).toMatchInlineSnapshot(` + } + `; + + const token = createBearerToken(secret, { sub }); + + const result = await translateQuery(neoSchema, query, { token, neo4jVersion: "5" }); + + expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` + "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 + WHERE ($param1 IN labels(this0) AND apoc.util.validatePredicate(NOT ($isAuthenticated = true AND (EXISTS { + MATCH (this0)<-[this2:DIRECTED]-(this3:Person) + WHERE ($param3 IS NOT NULL AND this2.year = $param3) + } AND NOT (EXISTS { + MATCH (this0)<-[this2:DIRECTED]-(this3:Person) + WHERE NOT ($param3 IS NOT NULL AND this2.year = $param3) + }))), \\"@neo4j/graphql/FORBIDDEN\\", [0])) + WITH collect({ node: this0 }) AS edges + WITH edges, size(edges) AS totalCount + CALL { + WITH edges + UNWIND edges AS edge + WITH edge.node AS this0 + RETURN collect({ node: { title: this0.title, __resolveType: \\"Movie\\" } }) AS var4 + } + RETURN { edges: var4, totalCount: totalCount } AS this" + `); + + expect(result.params).toMatchInlineSnapshot(` Object { "isAuthenticated": true, "param0": "something AND something", @@ -928,6 +559,5 @@ describe("Cypher -> fulltext -> Auth", () => { "param3": 2020, } `); - }); }); }); diff --git a/packages/graphql/tests/tck/fulltext/match.test.ts b/packages/graphql/tests/tck/fulltext/match.test.ts index f1fa1d431a..47560b7feb 100644 --- a/packages/graphql/tests/tck/fulltext/match.test.ts +++ b/packages/graphql/tests/tck/fulltext/match.test.ts @@ -26,7 +26,9 @@ describe("Cypher -> fulltext -> Match", () => { beforeAll(() => { typeDefs = /* GraphQL */ ` - type Movie @fulltext(indexes: [{ name: "MovieTitle", fields: ["title"] }]) @node { + type Movie + @fulltext(indexes: [{ indexName: "MovieTitle", queryName: "moviesByTitle", fields: ["title"] }]) + @node { title: String } `; @@ -39,8 +41,12 @@ describe("Cypher -> fulltext -> Match", () => { test("simple match with single fulltext property", async () => { const query = /* GraphQL */ ` query { - movies(fulltext: { MovieTitle: { phrase: "something AND something" } }) { - title + moviesByTitle(phrase: "something AND something") { + edges { + node { + title + } + } } } `; @@ -50,7 +56,15 @@ describe("Cypher -> fulltext -> Match", () => { expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 WHERE $param1 IN labels(this0) - RETURN this0 { .title } AS this" + WITH collect({ node: this0 }) AS edges + WITH edges, size(edges) AS totalCount + CALL { + WITH edges + UNWIND edges AS edge + WITH edge.node AS this0 + RETURN collect({ node: { title: this0.title, __resolveType: \\"Movie\\" } }) AS var2 + } + RETURN { edges: var2, totalCount: totalCount } AS this" `); expect(formatParams(result.params)).toMatchInlineSnapshot(` @@ -64,11 +78,12 @@ describe("Cypher -> fulltext -> Match", () => { test("match with where and single fulltext property", async () => { const query = /* GraphQL */ ` query { - movies( - fulltext: { MovieTitle: { phrase: "something AND something" } } - where: { title_EQ: "some-title" } - ) { - title + moviesByTitle(phrase: "something AND something", where: { node: { title_EQ: "some-title" } }) { + edges { + node { + title + } + } } } `; @@ -78,7 +93,15 @@ describe("Cypher -> fulltext -> Match", () => { expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 WHERE ($param1 IN labels(this0) AND this0.title = $param2) - RETURN this0 { .title } AS this" + WITH collect({ node: this0 }) AS edges + WITH edges, size(edges) AS totalCount + CALL { + WITH edges + UNWIND edges AS edge + WITH edge.node AS this0 + RETURN collect({ node: { title: this0.title, __resolveType: \\"Movie\\" } }) AS var2 + } + RETURN { edges: var2, totalCount: totalCount } AS this" `); expect(formatParams(result.params)).toMatchInlineSnapshot(` diff --git a/packages/graphql/tests/tck/fulltext/node-labels.test.ts b/packages/graphql/tests/tck/fulltext/node-labels.test.ts index 91d7a6eb8a..169d0d2f47 100644 --- a/packages/graphql/tests/tck/fulltext/node-labels.test.ts +++ b/packages/graphql/tests/tck/fulltext/node-labels.test.ts @@ -25,7 +25,7 @@ describe("Cypher -> fulltext -> Additional Labels", () => { test("simple match with single fulltext property and static additionalLabels", async () => { const typeDefs = /* GraphQL */ ` type Movie - @fulltext(indexes: [{ name: "MovieTitle", fields: ["title"] }]) + @fulltext(indexes: [{ indexName: "MovieTitle", queryName: "moviesByTitle", fields: ["title"] }]) @node(labels: ["Movie", "AnotherLabel"]) { title: String } @@ -37,8 +37,12 @@ describe("Cypher -> fulltext -> Additional Labels", () => { const query = /* GraphQL */ ` query { - movies(fulltext: { MovieTitle: { phrase: "something AND something" } }) { - title + moviesByTitle(phrase: "something AND something") { + edges { + node { + title + } + } } } `; @@ -48,7 +52,15 @@ describe("Cypher -> fulltext -> Additional Labels", () => { expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 WHERE ($param1 IN labels(this0) AND $param2 IN labels(this0)) - RETURN this0 { .title } AS this" + WITH collect({ node: this0 }) AS edges + WITH edges, size(edges) AS totalCount + CALL { + WITH edges + UNWIND edges AS edge + WITH edge.node AS this0 + RETURN collect({ node: { title: this0.title, __resolveType: \\"Movie\\" } }) AS var2 + } + RETURN { edges: var2, totalCount: totalCount } AS this" `); expect(formatParams(result.params)).toMatchInlineSnapshot(` @@ -63,7 +75,7 @@ describe("Cypher -> fulltext -> Additional Labels", () => { test("simple match with single fulltext property and jwt additionalLabels", async () => { const typeDefs = /* GraphQL */ ` type Movie - @fulltext(indexes: [{ name: "MovieTitle", fields: ["title"] }]) + @fulltext(indexes: [{ indexName: "MovieTitle", queryName: "moviesByTitle", fields: ["title"] }]) @node(labels: ["Movie", "$jwt.label"]) { title: String } @@ -80,8 +92,12 @@ describe("Cypher -> fulltext -> Additional Labels", () => { const query = /* GraphQL */ ` query { - movies(fulltext: { MovieTitle: { phrase: "something AND something" } }) { - title + moviesByTitle(phrase: "something AND something") { + edges { + node { + title + } + } } } `; @@ -94,7 +110,15 @@ describe("Cypher -> fulltext -> Additional Labels", () => { expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 WHERE ($param1 IN labels(this0) AND $param2 IN labels(this0)) - RETURN this0 { .title } AS this" + WITH collect({ node: this0 }) AS edges + WITH edges, size(edges) AS totalCount + CALL { + WITH edges + UNWIND edges AS edge + WITH edge.node AS this0 + RETURN collect({ node: { title: this0.title, __resolveType: \\"Movie\\" } }) AS var2 + } + RETURN { edges: var2, totalCount: totalCount } AS this" `); expect(formatParams(result.params)).toMatchInlineSnapshot(` diff --git a/packages/graphql/tests/tck/fulltext/score.test.ts b/packages/graphql/tests/tck/fulltext/score.test.ts index f334eb3bad..1d7c1b3275 100644 --- a/packages/graphql/tests/tck/fulltext/score.test.ts +++ b/packages/graphql/tests/tck/fulltext/score.test.ts @@ -26,7 +26,9 @@ describe("Cypher -> fulltext -> Score", () => { beforeAll(() => { typeDefs = /* GraphQL */ ` - type Movie @fulltext(indexes: [{ name: "MovieTitle", fields: ["title"] }]) @node { + type Movie + @fulltext(indexes: [{ indexName: "MovieTitle", queryName: "moviesByTitle", fields: ["title"] }]) + @node { title: String released: Int } @@ -40,11 +42,13 @@ describe("Cypher -> fulltext -> Score", () => { test("simple match with single property and score", async () => { const query = /* GraphQL */ ` query { - moviesFulltextMovieTitle(phrase: "a different name") { - score - movie { - title - released + moviesByTitle(phrase: "a different name") { + edges { + score + node { + title + released + } } } } @@ -55,7 +59,15 @@ describe("Cypher -> fulltext -> Score", () => { expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 WHERE $param1 IN labels(this0) - RETURN this0 { .title, .released } AS movie, var1 AS score" + WITH collect({ node: this0, score: var1 }) AS edges + WITH edges, size(edges) AS totalCount + CALL { + WITH edges + UNWIND edges AS edge + WITH edge.node AS this0, edge.score AS var1 + RETURN collect({ node: { title: this0.title, released: this0.released, __resolveType: \\"Movie\\" }, score: var1 }) AS var2 + } + RETURN { edges: var2, totalCount: totalCount } AS this" `); expect(formatParams(result.params)).toMatchInlineSnapshot(` @@ -69,11 +81,13 @@ describe("Cypher -> fulltext -> Score", () => { test("simple match with single property and score and filter", async () => { const query = /* GraphQL */ ` query { - moviesFulltextMovieTitle(phrase: "a different name", where: { movie: { released_GT: 2000 } }) { - score - movie { - title - released + moviesByTitle(phrase: "a different name", where: { node: { released_GT: 2000 } }) { + edges { + score + node { + title + released + } } } } @@ -84,7 +98,15 @@ describe("Cypher -> fulltext -> Score", () => { expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 WHERE ($param1 IN labels(this0) AND this0.released > $param2) - RETURN this0 { .title, .released } AS movie, var1 AS score" + WITH collect({ node: this0, score: var1 }) AS edges + WITH edges, size(edges) AS totalCount + CALL { + WITH edges + UNWIND edges AS edge + WITH edge.node AS this0, edge.score AS var1 + RETURN collect({ node: { title: this0.title, released: this0.released, __resolveType: \\"Movie\\" }, score: var1 }) AS var2 + } + RETURN { edges: var2, totalCount: totalCount } AS this" `); expect(formatParams(result.params)).toMatchInlineSnapshot(` @@ -102,10 +124,12 @@ describe("Cypher -> fulltext -> Score", () => { test("with score filtering", async () => { const query = /* GraphQL */ ` query { - moviesFulltextMovieTitle(phrase: "a different name", where: { score: { min: 0.5 } }) { - score - movie { - title + moviesByTitle(phrase: "a different name", where: { score: { min: 0.5 } }) { + edges { + score + node { + title + } } } } @@ -116,7 +140,15 @@ describe("Cypher -> fulltext -> Score", () => { expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 WHERE ($param1 IN labels(this0) AND var1 >= $param2) - RETURN this0 { .title } AS movie, var1 AS score" + WITH collect({ node: this0, score: var1 }) AS edges + WITH edges, size(edges) AS totalCount + CALL { + WITH edges + UNWIND edges AS edge + WITH edge.node AS this0, edge.score AS var1 + RETURN collect({ node: { title: this0.title, __resolveType: \\"Movie\\" }, score: var1 }) AS var2 + } + RETURN { edges: var2, totalCount: totalCount } AS this" `); expect(formatParams(result.params)).toMatchInlineSnapshot(` @@ -131,10 +163,12 @@ describe("Cypher -> fulltext -> Score", () => { test("with sorting", async () => { const query = /* GraphQL */ ` query { - moviesFulltextMovieTitle(phrase: "a different name", sort: { movie: { title: DESC } }) { - score - movie { - title + moviesByTitle(phrase: "a different name", sort: { node: { title: DESC } }) { + edges { + score + node { + title + } } } } @@ -145,9 +179,17 @@ describe("Cypher -> fulltext -> Score", () => { expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 WHERE $param1 IN labels(this0) - WITH * - ORDER BY this0.title DESC - RETURN this0 { .title } AS movie, var1 AS score" + WITH collect({ node: this0, score: var1 }) AS edges + WITH edges, size(edges) AS totalCount + CALL { + WITH edges + UNWIND edges AS edge + WITH edge.node AS this0, edge.score AS var1 + WITH * + ORDER BY this0.title DESC + RETURN collect({ node: { title: this0.title, __resolveType: \\"Movie\\" }, score: var1 }) AS var2 + } + RETURN { edges: var2, totalCount: totalCount } AS this" `); expect(formatParams(result.params)).toMatchInlineSnapshot(` @@ -161,10 +203,12 @@ describe("Cypher -> fulltext -> Score", () => { test("with score sorting", async () => { const query = /* GraphQL */ ` query { - moviesFulltextMovieTitle(phrase: "a different name", sort: { score: ASC }) { - score - movie { - title + moviesByTitle(phrase: "a different name", sort: { score: ASC }) { + edges { + score + node { + title + } } } } @@ -175,9 +219,17 @@ describe("Cypher -> fulltext -> Score", () => { expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 WHERE $param1 IN labels(this0) - WITH * - ORDER BY var1 ASC - RETURN this0 { .title } AS movie, var1 AS score" + WITH collect({ node: this0, score: var1 }) AS edges + WITH edges, size(edges) AS totalCount + CALL { + WITH edges + UNWIND edges AS edge + WITH edge.node AS this0, edge.score AS var1 + WITH * + ORDER BY var1 ASC + RETURN collect({ node: { title: this0.title, __resolveType: \\"Movie\\" }, score: var1 }) AS var2 + } + RETURN { edges: var2, totalCount: totalCount } AS this" `); expect(formatParams(result.params)).toMatchInlineSnapshot(` @@ -191,13 +243,12 @@ describe("Cypher -> fulltext -> Score", () => { test("with score and normal sorting", async () => { const query = /* GraphQL */ ` query { - moviesFulltextMovieTitle( - phrase: "a different name" - sort: [{ score: ASC }, { movie: { title: DESC } }] - ) { - score - movie { - title + moviesByTitle(phrase: "a different name", sort: [{ score: ASC }, { node: { title: DESC } }]) { + edges { + score + node { + title + } } } } @@ -208,9 +259,17 @@ describe("Cypher -> fulltext -> Score", () => { expect(formatCypher(result.cypher)).toMatchInlineSnapshot(` "CALL db.index.fulltext.queryNodes(\\"MovieTitle\\", $param0) YIELD node AS this0, score AS var1 WHERE $param1 IN labels(this0) - WITH * - ORDER BY var1 ASC, this0.title DESC - RETURN this0 { .title } AS movie, var1 AS score" + WITH collect({ node: this0, score: var1 }) AS edges + WITH edges, size(edges) AS totalCount + CALL { + WITH edges + UNWIND edges AS edge + WITH edge.node AS this0, edge.score AS var1 + WITH * + ORDER BY var1 ASC, this0.title DESC + RETURN collect({ node: { title: this0.title, __resolveType: \\"Movie\\" }, score: var1 }) AS var2 + } + RETURN { edges: var2, totalCount: totalCount } AS this" `); expect(formatParams(result.params)).toMatchInlineSnapshot(` diff --git a/packages/graphql/tests/tck/issues/5030.test.ts b/packages/graphql/tests/tck/issues/5030.test.ts index 264a95b516..7f5ec388ea 100644 --- a/packages/graphql/tests/tck/issues/5030.test.ts +++ b/packages/graphql/tests/tck/issues/5030.test.ts @@ -26,7 +26,9 @@ describe("https://github.com/neo4j/graphql/issues/5030", () => { beforeAll(() => { typeDefs = /* GraphQL */ ` - type Movie @fulltext(indexes: [{ name: "MovieTitle", fields: ["title"] }]) @node { + type Movie + @fulltext(indexes: [{ indexName: "MovieTitle", queryName: "moviesByTitle", fields: ["title"] }]) + @node { title: String released: Int }