Skip to content
5 changes: 5 additions & 0 deletions .changeset/witty-penguins-collect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@neo4j/graphql": major
---

Relationship type strings are now automatically escaped using backticks. If you were using backticks in the `type` argument of your `@relationship` directives, these should now be removed to avoid backticks being added into your relationship type labels.
4 changes: 4 additions & 0 deletions docs/modules/ROOT/pages/guides/v4-migration/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,10 @@ If it is not included, an error will be thrown.

As a result, in version 4.0.0, the following type definitions are invalid:

=== Relationship types are now automatically escaped

Relationship types are now automatically escaped. If you users have previously escaped their relationship types, you should now remove the escape strings as this is covered by the library.

[source, graphql, indent=0]
----
type Person {
Expand Down
13 changes: 13 additions & 0 deletions docs/modules/ROOT/pages/type-definitions/relationships.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -278,3 +278,16 @@ type Post {
----

The relationship at `User.posts` is considered a "many" relationship. Relationships such as the one above should always be of type `NonNullListType` and `NonNullNamedType`, meaning both the array and the type inside of it should have a `!`.

== Relationship types are automatically escaped

Relationship types are automatically escaped (wrapped with backticks ``), so there's no need to add escape characters around the relationship type names. For example, do this:

[source, graphql, indent=0]
----
type User {
name: String!
posts: [Post!]! @relationship(type: "HAS_POST", direction: OUT)
}
----

12 changes: 6 additions & 6 deletions packages/graphql/src/schema/create-relationship-fields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,10 @@ function createRelationshipFields({
const nodeCreateInput = schemaComposer.getITC(`${sourceName}CreateInput`);
const nodeUpdateInput = schemaComposer.getITC(`${sourceName}UpdateInput`);

let nodeConnectInput: InputTypeComposer<any> = undefined as unknown as InputTypeComposer<any>;
let nodeDeleteInput: InputTypeComposer<any> = undefined as unknown as InputTypeComposer<any>;
let nodeDisconnectInput: InputTypeComposer<any> = undefined as unknown as InputTypeComposer<any>;
let nodeRelationInput: InputTypeComposer<any> = undefined as unknown as InputTypeComposer<any>;
let nodeConnectInput: InputTypeComposer<any>;
let nodeDeleteInput: InputTypeComposer<any>;
let nodeDisconnectInput: InputTypeComposer<any>;
let nodeRelationInput: InputTypeComposer<any>;

if (relationshipFields.length) {
[nodeConnectInput, nodeDeleteInput, nodeDisconnectInput, nodeRelationInput] = [
Expand Down Expand Up @@ -432,10 +432,10 @@ function createRelationshipFields({
},
});

const fieldInputFields = {
const fieldInputFields: Record<string, string> = {
create,
connect,
} as Record<string, string>;
};

if (connectOrCreate) {
fieldInputFields.connectOrCreate = connectOrCreate;
Expand Down
2 changes: 1 addition & 1 deletion packages/graphql/src/schema/get-obj-field-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ function getObjFieldMeta({
validateResolvers: boolean;
callbacks?: Neo4jGraphQLCallbacks;
customResolvers?: IResolvers | Array<IResolvers>;
}) {
}): ObjectFields {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can also remove the cast at the end now (as ObjectFields), right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sadly not since it is potentially undefined due to obj.fields being potentially undefined.

const objInterfaceNames = [...(obj.interfaces || [])] as NamedTypeNode[];
const objInterfaces = interfaces.filter((i) => objInterfaceNames.map((n) => n.name.value).includes(i.name.value));

Expand Down
2 changes: 1 addition & 1 deletion packages/graphql/src/schema/get-relationship-meta.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ describe("getRelationshipMeta", () => {
const result = getRelationshipMeta(field);

expect(result).toMatchObject({
type: "ACTED_IN",
type: "`ACTED_IN`",
direction: "IN",
});
});
Expand Down
6 changes: 5 additions & 1 deletion packages/graphql/src/schema/get-relationship-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@
* limitations under the License.
*/

import Cypher from "@neo4j/cypher-builder";
import type { DirectiveNode, FieldDefinitionNode, StringValueNode } from "graphql";
import { RelationshipQueryDirectionOption } from "../constants";

type RelationshipMeta = {
direction: "IN" | "OUT";
type: string;
typeUnescaped: string;
properties?: string;
queryDirection: RelationshipQueryDirectionOption;
};
Expand Down Expand Up @@ -65,12 +67,14 @@ function getRelationshipMeta(
}

const direction = directionArg.value.value as "IN" | "OUT";
const type = typeArg.value.value;
const type = Cypher.utils.escapeLabel(typeArg.value.value);
const typeUnescaped = typeArg.value.value;
const properties = (propertiesArg?.value as StringValueNode)?.value;

return {
direction,
type,
typeUnescaped,
properties,
queryDirection,
};
Expand Down
8 changes: 4 additions & 4 deletions packages/graphql/src/schema/make-augmented-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -810,8 +810,8 @@ function makeAugmentedSchema(
}

["Mutation", "Query"].forEach((type) => {
const objectComposer = composer[type] as ObjectTypeComposer;
const cypherType = customResolvers[`customCypher${type}`] as ObjectTypeDefinitionNode;
const objectComposer: ObjectTypeComposer = composer[type];
const cypherType: ObjectTypeDefinitionNode = customResolvers[`customCypher${type}`];

if (cypherType) {
const objectFields = getObjFieldMeta({
Expand Down Expand Up @@ -885,7 +885,7 @@ function makeAugmentedSchema(

let parsedDoc = parse(generatedTypeDefs);

function definionNodeHasName(x: DefinitionNode): x is DefinitionNode & { name: NameNode } {
function definitionNodeHasName(x: DefinitionNode): x is DefinitionNode & { name: NameNode } {
return "name" in x;
}

Expand All @@ -903,7 +903,7 @@ function makeAugmentedSchema(
);
}

const documentNames = new Set(parsedDoc.definitions.filter(definionNodeHasName).map((x) => x.name.value));
const documentNames = new Set(parsedDoc.definitions.filter(definitionNodeHasName).map((x) => x.name.value));
const resolveMethods = getResolveAndSubscriptionMethods(composer);

const generatedResolveMethods: Record<string, any> = {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export function generateSubscribeMethod({
return false;
}
const relationFieldName = node.relationFields.find(
(r) => r.type === relationEventPayload.relationshipName
(r) => r.typeUnescaped === relationEventPayload.relationshipName
)?.fieldName;

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ export function filterByRelationshipProperties({
}): boolean {
const receivedEventProperties = receivedEvent.properties;
const receivedEventRelationshipType = receivedEvent.relationshipName;
const relationships = node.relationFields.filter((f) => f.type === receivedEventRelationshipType);
const relationships = node.relationFields.filter((f) => f.typeUnescaped === receivedEventRelationshipType);
if (!relationships.length) {
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ function getRelationField({
relationshipNameToRelationField = nodeToRelationFieldMap.get(node) as Map<string, RelationField | undefined>;
}
if (!relationshipNameToRelationField.has(relationshipName)) {
const relationField = node.relationFields.find((f) => f.type === relationshipName);
const relationField = node.relationFields.find((f) => f.typeUnescaped === relationshipName);
relationshipNameToRelationField.set(relationshipName, relationField);
}
return relationshipNameToRelationField.get(relationshipName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ describe("createConnectAndParams", () => {
relationFields: [
{
direction: "OUT",
type: "SIMILAR",
type: "`SIMILAR`",
typeUnescaped: "SIMILAR",
fieldName: "similarMovies",
queryDirection: RelationshipQueryDirectionOption.DEFAULT_DIRECTED,
inherited: false,
Expand Down Expand Up @@ -104,7 +105,7 @@ describe("createConnectAndParams", () => {
WITH connectedNodes, parentNodes
UNWIND parentNodes as this
UNWIND connectedNodes as this0_node
MERGE (this)-[:SIMILAR]->(this0_node)
MERGE (this)-[:\`SIMILAR\`]->(this0_node)
RETURN count(*) AS _
}
RETURN count(*) AS _
Expand All @@ -121,7 +122,7 @@ describe("createConnectAndParams", () => {
WITH connectedNodes, parentNodes
UNWIND parentNodes as this0_node
UNWIND connectedNodes as this0_node_similarMovies0_node
MERGE (this0_node)-[:SIMILAR]->(this0_node_similarMovies0_node)
MERGE (this0_node)-[:\`SIMILAR\`]->(this0_node_similarMovies0_node)
RETURN count(*) AS _
}
RETURN count(*) AS _
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ function createConnectAndParams({
relVariable: relationshipName,
fromVariable,
toVariable,
typename: relationField.type,
typename: relationField.typeUnescaped,
fromTypename,
toTypename,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ function mergeStatement({
relVariable: relationship.getCypher(env),
fromVariable: fromNode.getCypher(env),
toVariable: toNode.getCypher(env),
typename: relationField.type,
typename: relationField.typeUnescaped,
fromTypename,
toTypename,
});
Expand Down
2 changes: 1 addition & 1 deletion packages/graphql/src/translate/create-create-and-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ function createCreateAndParams({
relVariable: propertiesName,
fromVariable,
toVariable,
typename: relationField.type,
typename: relationField.typeUnescaped,
fromTypename,
toTypename,
});
Expand Down
2 changes: 1 addition & 1 deletion packages/graphql/src/translate/create-delete-and-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ function createDeleteAndParams({
relVariable: relationshipVariable,
fromVariable,
toVariable,
typename: relationField.type,
typename: relationField.typeUnescaped,
fromTypename,
toTypename,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ describe("createDisconnectAndParams", () => {
relationFields: [
{
direction: "OUT",
type: "SIMILAR",
typeUnescaped: "SIMILAR",
type: "`SIMILAR`",
fieldName: "similarMovies",
queryDirection: RelationshipQueryDirectionOption.DEFAULT_DIRECTED,
inherited: false,
Expand Down Expand Up @@ -107,7 +108,7 @@ describe("createDisconnectAndParams", () => {
"WITH this
CALL {
WITH this
OPTIONAL MATCH (this)-[this0_rel:SIMILAR]->(this0:Movie)
OPTIONAL MATCH (this)-[this0_rel:\`SIMILAR\`]->(this0:Movie)
WHERE this0.title = $this0_where_Movie_this0param0
CALL {
WITH this0, this0_rel, this
Expand All @@ -118,7 +119,7 @@ describe("createDisconnectAndParams", () => {
}
CALL {
WITH this, this0
OPTIONAL MATCH (this0)-[this0_similarMovies0_rel:SIMILAR]->(this0_similarMovies0:Movie)
OPTIONAL MATCH (this0)-[this0_similarMovies0_rel:\`SIMILAR\`]->(this0_similarMovies0:Movie)
WHERE this0_similarMovies0.title = $this0_disconnect_similarMovies0_where_Movie_this0_similarMovies0param0
CALL {
WITH this0_similarMovies0, this0_similarMovies0_rel, this0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ function createDisconnectAndParams({
relVariable: relVarName,
fromVariable,
toVariable,
typename: relationField.type,
typename: relationField.typeUnescaped,
fromTypename,
toTypename,
});
Expand Down
4 changes: 2 additions & 2 deletions packages/graphql/src/translate/create-update-and-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export default function createUpdateAndParams({
let returnMetaStatement = "";

updates.forEach((update, index) => {
const relationshipVariable = `${varName}_${relationField.type.toLowerCase()}${index}_relationship`;
const relationshipVariable = `${varName}_${relationField.typeUnescaped.toLowerCase()}${index}_relationship`;
const relTypeStr = `[${relationshipVariable}:${relationField.type}]`;
const variableName = `${varName}_${key}${relationField.union ? `_${refNode.name}` : ""}${index}`;

Expand Down Expand Up @@ -450,7 +450,7 @@ export default function createUpdateAndParams({
relVariable: propertiesName,
fromVariable,
toVariable,
typename: relationField.type,
typename: relationField.typeUnescaped,
fromTypename,
toTypename,
});
Expand Down
10 changes: 5 additions & 5 deletions packages/graphql/src/translate/translate-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,8 @@ export default async function translateUpdate({
const inStr = relationField.direction === "IN" ? "<-" : "-";
const outStr = relationField.direction === "OUT" ? "->" : "-";
refNodes.forEach((refNode) => {
const validateRelationshipExistance = `CALL apoc.util.validate(EXISTS((${varName})${inStr}[:${relationField.type}]${outStr}(:${refNode.name})),'Relationship field "%s.%s" cannot have more than one node linked',["${relationField.connectionPrefix}","${relationField.fieldName}"])`;
connectStrs.push(validateRelationshipExistance);
const validateRelationshipExistence = `CALL apoc.util.validate(EXISTS((${varName})${inStr}[:${relationField.type}]${outStr}(:${refNode.name})),'Relationship field "%s.%s" cannot have more than one node linked',["${relationField.connectionPrefix}","${relationField.fieldName}"])`;
connectStrs.push(validateRelationshipExistence);
});
}

Expand Down Expand Up @@ -281,8 +281,8 @@ export default async function translateUpdate({
const relTypeStr = `[${relationVarName}:${relationField.type}]`;

if (!relationField.typeMeta.array) {
const validateRelationshipExistance = `CALL apoc.util.validate(EXISTS((${varName})${inStr}[:${relationField.type}]${outStr}(:${refNode.name})),'Relationship field "%s.%s" cannot have more than one node linked',["${relationField.connectionPrefix}","${relationField.fieldName}"])`;
createStrs.push(validateRelationshipExistance);
const validateRelationshipExistence = `CALL apoc.util.validate(EXISTS((${varName})${inStr}[:${relationField.type}]${outStr}(:${refNode.name})),'Relationship field "%s.%s" cannot have more than one node linked',["${relationField.connectionPrefix}","${relationField.fieldName}"])`;
createStrs.push(validateRelationshipExistence);
}

const createAndParams = createCreateAndParams({
Expand Down Expand Up @@ -324,7 +324,7 @@ export default async function translateUpdate({
relVariable: propertiesName,
fromVariable,
toVariable,
typename: relationField.type,
typename: relationField.typeUnescaped,
fromTypename,
toTypename,
});
Expand Down
1 change: 1 addition & 0 deletions packages/graphql/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export interface BaseField {
*/
export interface RelationField extends BaseField {
direction: "OUT" | "IN";
typeUnescaped: string;
type: string;
connectionPrefix?: string;
inherited: boolean;
Expand Down
Loading