Skip to content
14 changes: 14 additions & 0 deletions .changeset/purple-hairs-glow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@neo4j/graphql": major
---

The `@id` directive has had a number of breaking changes.

The `unique` argument has been removed. In an effort to simplify directives, `@id` will now only only be responsible for the autogeneration of UUID values.
If you would like the property to also be backed by a unique node property constraint, use the `@unique` directive alongside `@id`.

The `autogenerate` argument has been removed. With this value set to `false` and the above removal of constraint management, this would make the directive a no-op.

The `global` argument has been removed. This quite key feature of specifying the globally unique identifier for Relay was hidden away inside the `@id` directive. This functionality has been moved into its own directive, `@relayId`, which is used with no arguments. The use of the `@relayId` directive also implies that the field will be backed by a unique node property constraint.

Note, if using the `@id` and `@relayId` directive together on the same field, this will be an autogenerated ID compatible with Relay, and be backed by a single unique node property constraint. If you wish to give this constraint a name, use the `@unique` directive also with the `constraintName` argument.
2 changes: 1 addition & 1 deletion packages/graphql/src/classes/Neo4jGraphQL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import type Node from "./Node";
import type Relationship from "./Relationship";
import checkNeo4jCompat from "./utils/verify-database";
import type { AssertIndexesAndConstraintsOptions } from "./utils/asserts-indexes-and-constraints";
import assertIndexesAndConstraints from "./utils/asserts-indexes-and-constraints";
import { assertIndexesAndConstraints } from "./utils/asserts-indexes-and-constraints";
import { wrapQueryAndMutation } from "../schema/resolvers/composition/wrap-query-and-mutation";
import type { WrapResolverArguments } from "../schema/resolvers/composition/wrap-query-and-mutation";
import { defaultFieldResolver } from "../schema/resolvers/field/defaultField";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,32 @@ export interface AssertIndexesAndConstraintsOptions {
create?: boolean;
}

export async function assertIndexesAndConstraints({
driver,
sessionConfig,
nodes,
options,
}: {
driver: Driver;
sessionConfig?: Neo4jGraphQLSessionConfig;
nodes: Node[];
options?: AssertIndexesAndConstraintsOptions;
}): Promise<void> {
await driver.verifyConnectivity();

const session = driver.session(sessionConfig);

try {
if (options?.create) {
await createIndexesAndConstraints({ nodes, session });
} else {
await checkIndexesAndConstraints({ nodes, session });
}
} finally {
await session.close();
}
}

async function createIndexesAndConstraints({ nodes, session }: { nodes: Node[]; session: Session }) {
const constraintsToCreate = await getMissingConstraints({ nodes, session });
const indexesToCreate: { indexName: string; label: string; properties: string[] }[] = [];
Expand Down Expand Up @@ -261,31 +287,3 @@ async function getMissingConstraints({

return missingConstraints;
}

async function assertIndexesAndConstraints({
driver,
sessionConfig,
nodes,
options,
}: {
driver: Driver;
sessionConfig?: Neo4jGraphQLSessionConfig;
nodes: Node[];
options?: AssertIndexesAndConstraintsOptions;
}): Promise<void> {
await driver.verifyConnectivity();

const session = driver.session(sessionConfig);

try {
if (options?.create) {
await createIndexesAndConstraints({ nodes, session });
} else {
await checkIndexesAndConstraints({ nodes, session });
}
} finally {
await session.close();
}
}

export default assertIndexesAndConstraints;
20 changes: 2 additions & 18 deletions packages/graphql/src/graphql/directives/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,10 @@
* limitations under the License.
*/

import { DirectiveLocation, GraphQLBoolean, GraphQLDirective, GraphQLNonNull } from "graphql";
import { DirectiveLocation, GraphQLDirective } from "graphql";

export const idDirective = new GraphQLDirective({
name: "id",
description:
"Indicates that the field is an identifier for the object type. By default; autogenerated, and has a unique node property constraint in the database.",
description: "Enables the autogeneration of UUID values for an ID field. The field becomes immutable.",
locations: [DirectiveLocation.FIELD_DEFINITION],
args: {
autogenerate: {
defaultValue: false,
type: new GraphQLNonNull(GraphQLBoolean),
},
unique: {
defaultValue: true,
type: new GraphQLNonNull(GraphQLBoolean),
},
global: {
description: "Opt-in to implementing the Node interface with a globally unique id",
type: new GraphQLNonNull(GraphQLBoolean),
defaultValue: false,
},
},
});
1 change: 1 addition & 0 deletions packages/graphql/src/graphql/directives/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export { limitDirective } from "./limit";
export { readonlyDirective } from "./readonly";
export { relationshipPropertiesDirective } from "./relationship-properties";
export { relationshipDirective } from "./relationship";
export { relayIdDirective } from "./relay-id";
export { timestampDirective } from "./timestamp";
export { uniqueDirective } from "./unique";
export { writeonlyDirective } from "./writeonly";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,11 @@
* limitations under the License.
*/

import { type DirectiveNode } from "graphql";
import { IDAnnotation } from "../../annotation/IDAnnotation";
import { parseArguments } from "../parse-arguments";
import { idDirective } from "../../../graphql/directives";
import { DirectiveLocation, GraphQLDirective } from "graphql";

export function parseIDAnnotation(directive: DirectiveNode): IDAnnotation {
const { autogenerate, unique, global } = parseArguments(idDirective, directive) as {
autogenerate: boolean;
unique: boolean;
global: boolean;
};

return new IDAnnotation({
autogenerate,
unique,
global,
});
}
export const relayIdDirective = new GraphQLDirective({
name: "relayId",
description:
"Mark the field to be used as the global node identifier for Relay. This field will be backed by a unique node property constraint.",
locations: [DirectiveLocation.FIELD_DEFINITION],
});
12 changes: 1 addition & 11 deletions packages/graphql/src/schema-model/annotation/IDAnnotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,4 @@
* limitations under the License.
*/

export class IDAnnotation {
public readonly autogenerate: boolean;
public readonly unique: boolean;
public readonly global: boolean;

constructor({ autogenerate, unique, global }: { autogenerate: boolean; unique: boolean; global: boolean }) {
this.autogenerate = autogenerate;
this.unique = unique;
this.global = global;
}
}
export class IDAnnotation {}

This file was deleted.

4 changes: 2 additions & 2 deletions packages/graphql/src/schema-model/parser/parse-annotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import { parseCoalesceAnnotation } from "./annotations-parser/coalesce-annotatio
import { parseCypherAnnotation } from "./annotations-parser/cypher-annotation";
import { parseCustomResolverAnnotation } from "./annotations-parser/custom-resolver-annotation";
import { parseDefaultAnnotation } from "./annotations-parser/default-annotation";
import { parseIDAnnotation } from "./annotations-parser/id-annotation";
import { parseFilterableAnnotation } from "./annotations-parser/filterable-annotation";
import { parseMutationAnnotation } from "./annotations-parser/mutation-annotation";
import { parsePluralAnnotation } from "./annotations-parser/plural-annotation";
Expand All @@ -44,6 +43,7 @@ import { parseSubscriptionsAuthorizationAnnotation } from "./annotations-parser/
import { filterTruthy } from "../../utils/utils";
import type { Annotation } from "../annotation/Annotation";
import { AnnotationsKey } from "../annotation/Annotation";
import { IDAnnotation } from "../annotation/IDAnnotation";

export function parseAnnotations(directives: readonly DirectiveNode[]): Annotation[] {
return filterTruthy(
Expand All @@ -66,7 +66,7 @@ export function parseAnnotations(directives: readonly DirectiveNode[]): Annotati
case AnnotationsKey.fulltext:
return parseFullTextAnnotation(directive);
case AnnotationsKey.id:
return parseIDAnnotation(directive);
return new IDAnnotation();
case AnnotationsKey.jwtClaim:
return parseJWTClaimAnnotation(directive);
case AnnotationsKey.jwtPayload:
Expand Down
4 changes: 2 additions & 2 deletions packages/graphql/src/schema/check-directive-combinations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function checkDirectiveCombinations(directives: readonly DirectiveNode[] = []):
customResolver: ["alias", "authentication", "authorization", "id", "readonly", "relationship", "writeonly"],
cypher: [],
default: [],
id: ["cypher", "customResolver", "relationship", "timestamp", "unique"],
id: ["cypher", "customResolver", "relationship", "timestamp"],
populatedBy: ["id", "default", "relationship"],
private: [],
readonly: ["cypher", "customResolver"],
Expand All @@ -47,7 +47,7 @@ function checkDirectiveCombinations(directives: readonly DirectiveNode[] = []):
"readonly",
],
timestamp: ["id", "unique"],
unique: ["cypher", "id", "customResolver", "relationship", "timestamp"],
unique: ["cypher", "customResolver", "relationship", "timestamp"],
writeonly: ["cypher", "customResolver"],
// OBJECT
node: [],
Expand Down
11 changes: 2 additions & 9 deletions packages/graphql/src/schema/get-nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,7 @@ function getNodes(
const globalIdFields = nodeFields.primitiveFields.filter((field) => field.isGlobalIdField);

if (globalIdFields.length > 1) {
throw new Error(
"Only one field may be decorated with an '@id' directive with the global argument set to `true`"
);
throw new Error("Only one field may be decorated with the `@relayId` directive");
}

const globalIdField = globalIdFields[0];
Expand All @@ -208,16 +206,11 @@ function getNodes(
const hasAlias = idField.directives?.find((x) => x.name.value === "alias");
if (!hasAlias) {
throw new Error(
`Type ${definition.name.value} already has a field "id." Either remove it, or if you need access to this property, consider using the "@alias" directive to access it via another field`
`Type ${definition.name.value} already has a field 'id', which is reserved for Relay global node identification.\nEither remove it, or if you need access to this property, consider using the '@alias' directive to access it via another field.`
);
}
}

if (globalIdField && !globalIdField.unique) {
throw new Error(
`Fields decorated with the "@id" directive must be unique in the database. Please remove it, or consider making the field unique`
);
}
const node = new Node({
name: definition.name.value,
interfaces: nodeInterfaces,
Expand Down
10 changes: 6 additions & 4 deletions packages/graphql/src/schema/get-obj-field-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ function getObjFieldMeta({
});
const typeMeta = getFieldTypeMeta(field.type);
const idDirective = directives.find((x) => x.name.value === "id");
const relayIdDirective = directives.find((x) => x.name.value === "relayId");
const defaultDirective = directives.find((x) => x.name.value === "default");
const coalesceDirective = directives.find((x) => x.name.value === "coalesce");
const timestampDirective = directives.find((x) => x.name.value === "timestamp");
Expand Down Expand Up @@ -188,6 +189,7 @@ function getObjFieldMeta({
"settable",
"subscriptionsAuthorization",
"filterable",
"relayId",
].includes(x.name.value)
),
arguments: [...(field.arguments || [])],
Expand Down Expand Up @@ -536,10 +538,10 @@ function getObjFieldMeta({

primitiveField.autogenerate = true;
}
const global = idDirective.arguments?.find((a) => a.name.value === "global");
if (global) {
primitiveField.isGlobalIdField = true;
}
}

if (relayIdDirective) {
primitiveField.isGlobalIdField = true;
}

if (defaultDirective) {
Expand Down
23 changes: 7 additions & 16 deletions packages/graphql/src/schema/make-augmented-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,40 +567,31 @@ describe("makeAugmentedSchema", () => {
test("should throw error if more than one @id directive field has the global argument set to true", () => {
const typeDefs = gql`
type User {
email: ID! @id(global: true)
name: ID! @id(global: true)
email: ID! @id @unique @relayId
name: ID! @id @unique @relayId
}
`;
expect(() => makeAugmentedSchema(typeDefs)).toThrow(
"Only one field may be decorated with an '@id' directive with the global argument set to `true`"
);
});
test("should throw if an @id directive has the global argument set to true, but the unique argument set to false", () => {
const typeDefs = gql`
type User {
email: ID! @id(global: true, unique: false)
}
`;
expect(() => makeAugmentedSchema(typeDefs)).toThrow(
`Fields decorated with the "@id" directive must be unique in the database. Please remove it, or consider making the field unique`
"Only one field may be decorated with the `@relayId` directive"
);
});

test("should throw if a type already contains an id field", () => {
const typeDefs = gql`
type User {
id: ID!
email: ID! @id(global: true)
email: ID! @id @unique @relayId
}
`;

expect(() => makeAugmentedSchema(typeDefs)).toThrow(
`Type User already has a field "id." Either remove it, or if you need access to this property, consider using the "@alias" directive to access it via another field`
`Type User already has a field 'id', which is reserved for Relay global node identification.\nEither remove it, or if you need access to this property, consider using the '@alias' directive to access it via another field`
);
});
test("should not throw if a type already contains an id field but the field is aliased", () => {
const typeDefs = gql`
type User {
dbId: ID! @id(global: true) @alias(property: "id")
dbId: ID! @id @unique @relayId @alias(property: "id")
}
`;
expect(() => makeAugmentedSchema(typeDefs)).not.toThrow();
Expand Down
Loading