diff --git a/docs/modules/ROOT/pages/type-definitions/types.adoc b/docs/modules/ROOT/pages/type-definitions/types.adoc index 35eb31cac5..8cc5263372 100644 --- a/docs/modules/ROOT/pages/type-definitions/types.adoc +++ b/docs/modules/ROOT/pages/type-definitions/types.adoc @@ -71,6 +71,22 @@ type Movie { } ---- +=== `Duration` + +ISO 8601 duration string stored as a https://neo4j.com/docs/cypher-manual/current/functions/temporal/duration/[duration] type. + +[source, graphql, indent=0] +---- +type Movie { + runningTime: Duration! +} +---- + +_Note:_ + +- Decimal values are not currently accepted on `[YMWD]` +- Comparisons are made according to the https://neo4j.com/developer/cypher/dates-datetimes-durations/#comparing-filtering-values[Cypher Developer Guide] + === `LocalDateTime` "YYYY-MM-DDTHH:MM:SS" datetime string stored as a https://neo4j.com/docs/cypher-manual/current/functions/temporal/#functions-localdatetime[LocalDateTime] temporal type. diff --git a/packages/graphql/src/schema/get-where-fields.ts b/packages/graphql/src/schema/get-where-fields.ts index 90e6eaf1cc..13a9d76fa5 100644 --- a/packages/graphql/src/schema/get-where-fields.ts +++ b/packages/graphql/src/schema/get-where-fields.ts @@ -63,9 +63,17 @@ function getWhereFields({ res[`${f.fieldName}_NOT_IN`] = `[${f.typeMeta.input.where.pretty}]`; if ( - ["Float", "Int", "BigInt", "DateTime", "Date", "LocalDateTime", "Time", "LocalTime"].includes( - f.typeMeta.name - ) + [ + "Float", + "Int", + "BigInt", + "DateTime", + "Date", + "LocalDateTime", + "Time", + "LocalTime", + "Duration", + ].includes(f.typeMeta.name) ) { ["_LT", "_LTE", "_GT", "_GTE"].forEach((comparator) => { res[`${f.fieldName}${comparator}`] = f.typeMeta.name; diff --git a/packages/graphql/src/schema/scalars/Duration.test.ts b/packages/graphql/src/schema/scalars/Duration.test.ts new file mode 100644 index 0000000000..edd3128f7d --- /dev/null +++ b/packages/graphql/src/schema/scalars/Duration.test.ts @@ -0,0 +1,60 @@ +/* + * 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 { parseDuration } from "./Duration"; + +type ParsedDuration = ReturnType; + +describe("Duration Scalar", () => { + test.each([42, () => 5, { a: 3, b: 4 }, null, undefined])("should not match %p and throw error", (value) => + expect(() => parseDuration(value)).toThrow(TypeError) + ); + test.each([ + "P", + "PT", + "P233WT4H", + "P5.2Y4M", + "P18871104T12:00:00", + "P1887-11-04T120000", + ])("should not match %s and throw error", (value) => expect(() => parseDuration(value)).toThrow(TypeError)); + test.each<[string, ParsedDuration]>([ + ["P2Y", { months: 2 * 12, days: 0, seconds: 0, nanoseconds: 0 }], + ["P2Y-3M", { months: 2 * 12 - 3, days: 0, seconds: 0, nanoseconds: 0 }], + ["-P2Y-3M", { months: -2 * 12 + 3, days: 0, seconds: 0, nanoseconds: 0 }], + ["P3M", { months: 3, days: 0, seconds: 0, nanoseconds: 0 }], + ["P87D", { months: 0, days: 87, seconds: 0, nanoseconds: 0 }], + ["P15W", { months: 0, days: 15 * 7, seconds: 0, nanoseconds: 0 }], + ["P-15W", { months: 0, days: -15 * 7, seconds: 0, nanoseconds: 0 }], + ["-P-15W", { months: 0, days: 15 * 7, seconds: 0, nanoseconds: 0 }], + ["PT50H", { months: 0, days: 0, seconds: 50 * 60 * 60, nanoseconds: 0 }], + ["P4Y-5M-3DT5H", { months: 4 * 12 - 5, days: -3, seconds: 5 * 3600, nanoseconds: 0 }], + ["PT30M", { months: 0, days: 0, seconds: 30 * 60, nanoseconds: 0 }], + ["PT6.5S", { months: 0, days: 0, seconds: 6, nanoseconds: 500000000 }], + ["P34.5Y", { months: 414, days: 0, seconds: 0, nanoseconds: 0 }], + ["P6.5M", { months: 6, days: 15, seconds: 18873, nanoseconds: 0 }], + ["P3.5D", { months: 0, days: 3, seconds: 43200, nanoseconds: 0 }], + ["P7M-4.5D", { months: 7, days: -4, seconds: -43200, nanoseconds: 0 }], + ["P6M-4DT0.75H", { months: 6, days: -4, seconds: 2700, nanoseconds: 0 }], + ["P6Y30M16DT30M", { months: 6 * 12 + 30, days: 16, seconds: 30 * 60, nanoseconds: 0 }], + ["P18870605T120000", { months: 1887 * 12 + 6, days: 5, seconds: 12 * 60 * 60, nanoseconds: 0 }], + ["P1887-06-05T12:00:00", { months: 1887 * 12 + 6, days: 5, seconds: 12 * 60 * 60, nanoseconds: 0 }], + ])("should match and parse %s correctly", (duration, parsed) => + expect(parseDuration(duration)).toStrictEqual(parsed) + ); +}); diff --git a/packages/graphql/src/schema/scalars/Duration.ts b/packages/graphql/src/schema/scalars/Duration.ts new file mode 100644 index 0000000000..0e9e17577c --- /dev/null +++ b/packages/graphql/src/schema/scalars/Duration.ts @@ -0,0 +1,149 @@ +/* + * 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 { GraphQLError, GraphQLScalarType, Kind } from "graphql"; +import neo4j from "neo4j-driver"; + +// Matching P[nY][nM][nD][T[nH][nM][nS]] | PnW | PYYYYMMDDTHHMMSS | PYYYY-MM-DDTHH:MM:SS +// For unit based duration a decimal value can only exist on the smallest unit(e.g. P2Y4.5M matches P2.5Y4M does not) +// Similar constraint allows for only decimal seconds on date time based duration +const DURATION_REGEX = /^(?-?)P(?!$)(?:(?:(?-?\d+(?:\.\d+(?=Y$))?)Y)?(?:(?-?\d+(?:\.\d+(?=M$))?)M)?(?:(?-?\d+(?:\.\d+(?=D$))?)D)?(?:T(?=-?\d)(?:(?-?\d+(?:\.\d+(?=H$))?)H)?(?:(?-?\d+(?:\.\d+(?=M$))?)M)?(?:(?-?\d+(?:\.\d+(?=S$))?)S)?)?|(?-?\d+(?:\.\d+)?)W|(?\d{4})(?-?)(?[0]\d|1[0-2])\k(?\d{2})T(?[01]\d|2[0-3])(?(?:(?<=-\w+?):)|(?<=^-?\w+))(?[0-5]\d)\k(?[0-5]\d(?:\.\d+)?))$/; + +// Normalized components per https://neo4j.com/docs/cypher-manual/current/syntax/operators/#cypher-ordering +const MONTHS_PER_YEAR = 12; +const DAYS_PER_YEAR = 365.2425; +const DAYS_PER_MONTH = DAYS_PER_YEAR / MONTHS_PER_YEAR; +const DAYS_PER_WEEK = 7; +const HOURS_PER_DAY = 24; +const MINUTES_PER_HOUR = 60; +const SECONDS_PER_MINUTE = 60; +const SECONDS_PER_HOUR = SECONDS_PER_MINUTE * MINUTES_PER_HOUR; + +export const parseDuration = (value: string) => { + const match = DURATION_REGEX.exec(value); + + if (!match) { + throw new TypeError(`Value must be formatted as Duration: ${value}`); + } + + const { + negated, + // P[nY][nM][nD][T[nH][nM][nS]] + yearUnit = 0, + monthUnit = 0, + dayUnit = 0, + hourUnit = 0, + minuteUnit = 0, + secondUnit = 0, + // PnW + weekUnit = 0, + // PYYYYMMDDTHHMMSS | PYYYY-MM-DDTHH:MM:SS + yearDT = 0, + monthDT = 0, + dayDT = 0, + hourDT = 0, + minuteDT = 0, + secondDT = 0, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + } = match.groups!; + + // NOTE: xUnit and xDT cannot both be nonzero by construction => (xUnit + xDT) = xUnit | xDT | 0 + const years = +yearUnit + +yearDT; + const months = +monthUnit + +monthDT; + const weeks = +weekUnit; + const days = +dayUnit + +dayDT; + const hours = +hourUnit + +hourDT; + const minutes = +minuteUnit + +minuteDT; + const seconds = +secondUnit + +secondDT; + + // Splits a component into a whole part and remainder + const splitComponent = (component: number): [number, number] => [ + Math.trunc(component), + +(component % 1).toPrecision(9), + ]; + + // Calculate months based off of months and years + const [wholeMonths, remainderMonths] = splitComponent(months + years * MONTHS_PER_YEAR); + + // Calculate days based off of days, weeks, and remainder of months + const [wholeDays, remainderDays] = splitComponent(days + weeks * DAYS_PER_WEEK + remainderMonths * DAYS_PER_MONTH); + + // Calculate seconds based off of remainder of days, hours, minutes, and seconds + const splitHoursInSeconds = splitComponent((hours + remainderDays * HOURS_PER_DAY) * SECONDS_PER_HOUR); + const splitMinutesInSeconds = splitComponent(minutes * SECONDS_PER_MINUTE); + const splitSeconds = splitComponent(seconds); + // Total seconds by adding splits of hour minute second + const [wholeSeconds, remainderSeconds] = splitHoursInSeconds.map( + (p, i) => p + splitMinutesInSeconds[i] + splitSeconds[i] + ); + + // Calculate nanoseconds based off of remainder of seconds + const wholeNanoseconds = +remainderSeconds.toFixed(9) * 1000000000; + + // Whether total duration is negative + const coefficient = negated ? -1 : 1; + // coefficient of duration and % may negate zero: converts -0 -> 0 + const unsignZero = (a: number) => (Object.is(a, -0) ? 0 : a); + + return { + months: unsignZero(coefficient * wholeMonths), + days: unsignZero(coefficient * wholeDays), + seconds: unsignZero(coefficient * wholeSeconds), + nanoseconds: unsignZero(coefficient * wholeNanoseconds), + }; +}; + +const parse = (value: any) => { + const { months, days, seconds, nanoseconds } = parseDuration(value); + + return new neo4j.types.Duration(months, days, seconds, nanoseconds); +}; + +export default new GraphQLScalarType({ + name: "Duration", + description: "A duration, represented as an ISO 8601 duration string", + serialize: (value: any) => { + if (!(typeof value === "string" || value instanceof neo4j.types.Duration)) { + throw new TypeError(`Value must be of type string: ${value}`); + } + + if (value instanceof neo4j.types.Duration) { + return value.toString(); + } + + if (!DURATION_REGEX.test(value)) { + throw new TypeError(`Value must be formatted as Duration: ${value}`); + } + + return value; + }, + parseValue: (value) => { + if (typeof value !== "string") { + throw new GraphQLError(`Only strings can be validated as Duration, but received: ${value}`); + } + + return parse(value); + }, + parseLiteral: (ast) => { + if (ast.kind !== Kind.STRING) { + throw new GraphQLError(`Only strings can be validated as Duration, but received: ${ast.kind}`); + } + return parse(ast.value); + }, +}); diff --git a/packages/graphql/src/schema/scalars/index.ts b/packages/graphql/src/schema/scalars/index.ts index 792766be62..e0051c13db 100644 --- a/packages/graphql/src/schema/scalars/index.ts +++ b/packages/graphql/src/schema/scalars/index.ts @@ -20,6 +20,7 @@ export { default as BigInt } from "./BigInt"; export { default as DateTime } from "./DateTime"; export { default as Date } from "./Date"; +export { default as Duration } from "./Duration"; export { default as LocalDateTime } from "./LocalDateTime"; export { default as Time } from "./Time"; export { default as LocalTime } from "./LocalTime"; diff --git a/packages/graphql/src/translate/create-where-and-params.ts b/packages/graphql/src/translate/create-where-and-params.ts index 52f949b647..1e76b6bd02 100644 --- a/packages/graphql/src/translate/create-where-and-params.ts +++ b/packages/graphql/src/translate/create-where-and-params.ts @@ -57,6 +57,11 @@ function createWhereAndParams({ let dbFieldName = mapToDbProperty(node, key); const pointField = node.pointFields.find((x) => key.startsWith(x.fieldName)); + // Comparison operations requires adding dates to durations + // See https://neo4j.com/developer/cypher/dates-datetimes-durations/#comparing-filtering-values + const durationField = node.primitiveFields.find( + (x) => key.startsWith(x.fieldName) && x.typeMeta.name === "Duration" + ); if (key.endsWith("_NOT")) { const [fieldName] = key.split("_NOT"); @@ -493,11 +498,17 @@ function createWhereAndParams({ ? `coalesce(${varName}.${dbFieldName}, ${coalesceValue})` : `${varName}.${dbFieldName}`; - res.clauses.push( - pointField - ? `distance(${varName}.${dbFieldName}, point($${param}.point)) < $${param}.distance` - : `${property} < $${param}` - ); + let clause = `${property} < $${param}`; + + if (pointField) { + clause = `distance(${varName}.${fieldName}, point($${param}.point)) < $${param}.distance`; + } + + if (durationField) { + clause = `datetime() + ${property} < datetime() + $${param}`; + } + + res.clauses.push(clause); res.params[param] = value; return res; @@ -515,11 +526,17 @@ function createWhereAndParams({ ? `coalesce(${varName}.${dbFieldName}, ${coalesceValue})` : `${varName}.${dbFieldName}`; - res.clauses.push( - pointField - ? `distance(${varName}.${dbFieldName}, point($${param}.point)) <= $${param}.distance` - : `${property} <= $${param}` - ); + let clause = `${property} <= $${param}`; + + if (pointField) { + clause = `distance(${varName}.${fieldName}, point($${param}.point)) <= $${param}.distance`; + } + + if (durationField) { + clause = `datetime() + ${property} <= datetime() + $${param}`; + } + + res.clauses.push(clause); res.params[param] = value; return res; @@ -537,11 +554,17 @@ function createWhereAndParams({ ? `coalesce(${varName}.${dbFieldName}, ${coalesceValue})` : `${varName}.${dbFieldName}`; - res.clauses.push( - pointField - ? `distance(${varName}.${dbFieldName}, point($${param}.point)) > $${param}.distance` - : `${property} > $${param}` - ); + let clause = `${property} > $${param}`; + + if (pointField) { + clause = `distance(${varName}.${fieldName}, point($${param}.point)) > $${param}.distance`; + } + + if (durationField) { + clause = `datetime() + ${property} > datetime() + $${param}`; + } + + res.clauses.push(clause); res.params[param] = value; return res; @@ -559,11 +582,17 @@ function createWhereAndParams({ ? `coalesce(${varName}.${dbFieldName}, ${coalesceValue})` : `${varName}.${dbFieldName}`; - res.clauses.push( - pointField - ? `distance(${varName}.${dbFieldName}, point($${param}.point)) >= $${param}.distance` - : `${property} >= $${param}` - ); + let clause = `${property} >= $${param}`; + + if (pointField) { + clause = `distance(${varName}.${fieldName}, point($${param}.point)) >= $${param}.distance`; + } + + if (durationField) { + clause = `datetime() + ${property} >= datetime() + $${param}`; + } + + res.clauses.push(clause); res.params[param] = value; return res; diff --git a/packages/graphql/src/translate/where/create-node-where-and-params.ts b/packages/graphql/src/translate/where/create-node-where-and-params.ts index 8794476a67..8ae791aa34 100644 --- a/packages/graphql/src/translate/where/create-node-where-and-params.ts +++ b/packages/graphql/src/translate/where/create-node-where-and-params.ts @@ -69,6 +69,11 @@ function createNodeWhereAndParams({ const operator = match?.groups?.operator; const pointField = node.pointFields.find((x) => x.fieldName === fieldName); + // Comparison operations requires adding dates to durations + // See https://neo4j.com/developer/cypher/dates-datetimes-durations/#comparing-filtering-values + const durationField = node.scalarFields.find( + (x) => x.fieldName === fieldName && x.typeMeta.name === "Duration" + ); const coalesceValue = [...node.primitiveFields, ...node.temporalFields].find((f) => fieldName === f.fieldName) ?.coalesceValue; @@ -208,12 +213,19 @@ function createNodeWhereAndParams({ } if (operator && ["LT", "LTE", "GTE", "GT"].includes(operator)) { - res.clauses.push( - pointField - ? `distance(${property}, point($${param}.point)) ${operators[operator]} $${param}.distance` - : `${property} ${operators[operator]} $${param}` - ); + let clause = `${property} ${operators[operator]} $${param}`; + + if (pointField) { + clause = `distance(${property}, point($${param}.point)) ${operators[operator]} $${param}.distance`; + } + + if (durationField) { + clause = `datetime() + ${property} ${operators[operator]} datetime() + $${param}`; + } + + res.clauses.push(clause); res.params[key] = value; + return res; } diff --git a/packages/graphql/src/translate/where/create-relationship-where-and-params.ts b/packages/graphql/src/translate/where/create-relationship-where-and-params.ts index 8ff22b6d5a..4f395acade 100644 --- a/packages/graphql/src/translate/where/create-relationship-where-and-params.ts +++ b/packages/graphql/src/translate/where/create-relationship-where-and-params.ts @@ -55,6 +55,11 @@ function createRelationshipWhereAndParams({ const operator = match?.groups?.operator; const pointField = relationship.pointFields.find((f) => f.fieldName === fieldName); + // Comparison operations requires adding dates to durations + // See https://neo4j.com/developer/cypher/dates-datetimes-durations/#comparing-filtering-values + const durationField = relationship.primitiveFields.find( + (f) => f.fieldName === fieldName && f.typeMeta.name === "Duration" + ); const coalesceValue = ([ ...relationship.temporalFields, @@ -157,10 +162,20 @@ function createRelationshipWhereAndParams({ } if (operator && ["DISTANCE", "LT", "LTE", "GTE", "GT"].includes(operator)) { + let left = property; + let right = `$${param}`; + if (pointField) { + left = `distance(${property}, point($${param}.point))`; + right = `$${param}.distance`; + } + if (durationField) { + left = `datetime() + ${property}`; + right = `datetime() + $${param}`; + } const clause = createFilter({ - left: pointField ? `distance(${property}, point($${param}.point))` : property, + left, operator, - right: pointField ? `$${param}.distance` : `$${param}`, + right, }); res.clauses.push(clause); res.params[key] = value; diff --git a/packages/graphql/tests/integration/types/duration.int.test.ts b/packages/graphql/tests/integration/types/duration.int.test.ts new file mode 100644 index 0000000000..333f84fce1 --- /dev/null +++ b/packages/graphql/tests/integration/types/duration.int.test.ts @@ -0,0 +1,437 @@ +/* + * 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 faker from "faker"; +import { graphql } from "graphql"; +import neo4jDriver, { Driver } from "neo4j-driver"; +import { generate } from "randomstring"; +import neo4j from "../neo4j"; +import { Neo4jGraphQL } from "../../../src/classes"; +import { parseDuration } from "../../../src/schema/scalars/Duration"; + +describe("Duration", () => { + let driver: Driver; + + beforeAll(async () => { + driver = await neo4j(); + }); + + afterAll(async () => { + await driver.close(); + }); + + describe("create", () => { + test("should create a movie (with a Duration)", async () => { + const session = driver.session(); + + const typeDefs = ` + type Movie { + id: ID! + duration: Duration! + } + `; + + const { schema } = new Neo4jGraphQL({ + typeDefs, + }); + + const id = generate({ readable: false }); + const years = faker.random.number({ min: -6, max: 6 }); + const months = faker.random.number({ min: -10, max: 10 }); + const days = faker.random.number({ min: -50, max: 50 }); + const minutes = faker.random.float(); + + const duration = `P${years}Y${months}M${days}DT${minutes}M`; + const parsedDuration = parseDuration(duration); + + try { + const mutation = ` + mutation ($id: ID!, $duration: Duration!) { + createMovies(input: { id: $id, duration: $duration }) { + movies { + id + duration + } + } + } + `; + + const graphqlResult = await graphql({ + schema, + source: mutation, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { id, duration }, + }); + + expect(graphqlResult.errors).toBeFalsy(); + + const graphqlMovie: { id: string; duration: string } = graphqlResult.data?.createMovies.movies[0]; + expect(graphqlMovie).toBeDefined(); + expect(graphqlMovie.id).toBe(id); + expect(parseDuration(graphqlMovie.duration)).toStrictEqual(parsedDuration); + + const neo4jResult = await session.run( + ` + MATCH (movie:Movie {id: $id}) + RETURN movie {.id, .duration} as movie + `, + { id } + ); + + const neo4jMovie: { id: string; duration: any } = neo4jResult.records[0].toObject().movie; + expect(neo4jMovie).toBeDefined(); + expect(neo4jMovie.id).toEqual(id); + expect(neo4jDriver.isDuration(neo4jMovie.duration)).toBe(true); + expect(parseDuration(neo4jMovie.duration.toString())).toStrictEqual(parsedDuration); + } finally { + await session.close(); + } + }); + + test("should create a movie (with many Durations)", async () => { + const session = driver.session(); + + const typeDefs = ` + type Movie { + id: ID! + durations: [Duration!]! + } + `; + + const { schema } = new Neo4jGraphQL({ + typeDefs, + }); + + const id = generate({ readable: false }); + const durations = ["P34Y4M2DT23.44H", "P-34W", "P19980314T120000", "P4Y-5M-3.75D"]; + const parsedDurations = durations.map((duration) => parseDuration(duration)); + + try { + const mutation = ` + mutation ($id: ID!, $durations: [Duration!]!) { + createMovies(input: { id: $id, durations: $durations }) { + movies { + id + durations + } + } + } + `; + + const graphqlResult = await graphql({ + schema, + source: mutation, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { id, durations }, + }); + + expect(graphqlResult.errors).toBeFalsy(); + + const graphqlMovie: { id: string; durations: string[] } = graphqlResult.data?.createMovies.movies[0]; + expect(graphqlMovie).toBeDefined(); + expect(graphqlMovie.id).toBe(id); + expect(graphqlMovie.durations).toHaveLength(durations.length); + + const parsedGraphQLDurations = graphqlMovie.durations.map((duration) => parseDuration(duration)); + + parsedDurations.forEach((parsedDuration) => { + expect(parsedGraphQLDurations).toContainEqual(parsedDuration); + }); + + const neo4jResult = await session.run( + ` + MATCH (movie:Movie {id: $id}) + RETURN movie {.id, .durations} as movie + `, + { id } + ); + + const neo4jMovie: { id: string; durations: any[] } = neo4jResult.records[0].toObject().movie; + expect(neo4jMovie).toBeDefined(); + expect(neo4jMovie.id).toEqual(id); + expect(neo4jMovie.durations).toHaveLength(durations.length); + + neo4jMovie.durations.forEach((duration) => { + expect(neo4jDriver.isDuration(duration)).toBe(true); + }); + + const parsedNeo4jDurations = neo4jMovie.durations.map((duration) => parseDuration(duration.toString())); + + parsedDurations.forEach((parsedDuration) => { + expect(parsedNeo4jDurations).toContainEqual(parsedDuration); + }); + } finally { + await session.close(); + } + }); + }); + + describe("update", () => { + test("should update a movie (with a Duration)", async () => { + const session = driver.session(); + + const typeDefs = ` + type Movie { + id: ID! + duration: Duration + } + `; + + const { schema } = new Neo4jGraphQL({ + typeDefs, + }); + + const id = generate({ readable: false }); + const duration = "-P5Y6M"; + const parsedDuration = parseDuration(duration); + + try { + await session.run( + ` + CREATE (movie:Movie) + SET movie = $movie + `, + { movie: { id } } + ); + + const mutation = ` + mutation ($id: ID!, $duration: Duration) { + updateMovies(where: { id: $id }, update: { duration: $duration }) { + movies { + id + duration + } + } + } + `; + + const graphqlResult = await graphql({ + schema, + source: mutation, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { id, duration }, + }); + + expect(graphqlResult.errors).toBeFalsy(); + + const graphqlMovie: { id: string; duration: string } = graphqlResult.data?.updateMovies.movies[0]; + expect(graphqlMovie).toBeDefined(); + expect(graphqlMovie.id).toEqual(id); + expect(parseDuration(graphqlMovie.duration)).toStrictEqual(parsedDuration); + + const neo4jResult = await session.run( + ` + MATCH (movie:Movie {id: $id}) + RETURN movie {.id, .duration} as movie + `, + { id } + ); + + const neo4jMovie: { id: string; duration: any } = neo4jResult.records[0].toObject().movie; + expect(neo4jMovie).toBeDefined(); + expect(neo4jMovie.id).toEqual(id); + expect(neo4jDriver.isDuration(neo4jMovie.duration)).toBe(true); + expect(parseDuration(neo4jMovie.duration.toString())).toStrictEqual(parsedDuration); + } finally { + await session.close(); + } + }); + }); + + describe("filter", () => { + test("should filter based on duration equality", async () => { + const session = driver.session(); + + const typeDefs = ` + type Movie { + id: ID! + duration: Duration! + } + `; + + const { schema } = new Neo4jGraphQL({ + typeDefs, + }); + + const id = generate({ readable: false }); + const days = 4; + const duration = `P${days}D`; + const parsedDuration = parseDuration(duration); + const neo4jDuration = new neo4jDriver.types.Duration(0, days, 0, 0); + + try { + await session.run( + ` + CREATE (movie:Movie) + SET movie = $movie + `, + { movie: { id, duration: neo4jDuration } } + ); + + const query = ` + query ($id: ID!, $duration: Duration!) { + movies(where: { id: $id, duration: $duration }) { + id + duration + } + } + `; + + const graphqlResult = await graphql({ + schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { id, duration }, + }); + + expect(graphqlResult.errors).toBeFalsy(); + + const graphqlMovie: { id: string; duration: string } = graphqlResult.data?.movies[0]; + expect(graphqlMovie).toBeDefined(); + expect(graphqlMovie.id).toEqual(id); + expect(parseDuration(graphqlMovie.duration)).toStrictEqual(parsedDuration); + } finally { + await session.close(); + } + }); + test("should filter based on duration comparison", () => + Promise.all( + ["LT", "LTE", "GT", "GTE"].map(async (filter) => { + const session = driver.session(); + + const typeDefs = ` + type Movie { + id: ID! + duration: Duration! + } + `; + + const { schema } = new Neo4jGraphQL({ typeDefs }); + + const longId = generate({ readable: false }); + const long = "P2Y"; + const parsedLong = parseDuration(long); + const neo4jLong = new neo4jDriver.types.Duration( + parsedLong.months, + parsedLong.days, + parsedLong.seconds, + parsedLong.nanoseconds + ); + + const mediumId = generate({ readable: false }); + const medium = "P2M"; + const parsedMedium = parseDuration(medium); + const neo4jMedium = new neo4jDriver.types.Duration( + parsedMedium.months, + parsedMedium.days, + parsedMedium.seconds, + parsedMedium.nanoseconds + ); + + const shortId = generate({ readable: false }); + const short = "P2D"; + const parsedShort = parseDuration(short); + const neo4jShort = new neo4jDriver.types.Duration( + parsedShort.months, + parsedShort.days, + parsedShort.seconds, + parsedShort.nanoseconds + ); + + try { + await session.run( + ` + CREATE (long:Movie) + SET long = $long + CREATE (medium:Movie) + SET medium = $medium + CREATE (short:Movie) + SET short = $short + `, + { + long: { id: longId, duration: neo4jLong }, + medium: { id: mediumId, duration: neo4jMedium }, + short: { id: shortId, duration: neo4jShort }, + } + ); + + const query = ` + query ($where: MovieWhere!) { + movies( + where: $where + options: { sort: [{ duration: ASC }]} + ) { + id + duration + } + } + `; + + const graphqlResult = await graphql({ + schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { + where: { id_IN: [longId, mediumId, shortId], [`duration_${filter}`]: medium }, + }, + }); + + expect(graphqlResult.errors).toBeUndefined(); + + const graphqlMovies: { id: string; duration: string }[] = graphqlResult.data?.movies; + expect(graphqlMovies).toBeDefined(); + + /* eslint-disable jest/no-conditional-expect */ + if (filter === "LT") { + expect(graphqlMovies).toHaveLength(1); + expect(graphqlMovies[0].id).toBe(shortId); + expect(parseDuration(graphqlMovies[0].duration)).toStrictEqual(parsedShort); + } + + if (filter === "LTE") { + expect(graphqlMovies).toHaveLength(2); + expect(graphqlMovies[0].id).toBe(shortId); + expect(parseDuration(graphqlMovies[0].duration)).toStrictEqual(parsedShort); + + expect(graphqlMovies[1].id).toBe(mediumId); + expect(parseDuration(graphqlMovies[1].duration)).toStrictEqual(parsedMedium); + } + + if (filter === "GT") { + expect(graphqlMovies).toHaveLength(1); + expect(graphqlMovies[0].id).toBe(longId); + expect(parseDuration(graphqlMovies[0].duration)).toStrictEqual(parsedLong); + } + + if (filter === "GTE") { + expect(graphqlMovies).toHaveLength(2); + expect(graphqlMovies[0].id).toBe(mediumId); + expect(parseDuration(graphqlMovies[0].duration)).toStrictEqual(parsedMedium); + + expect(graphqlMovies[1].id).toBe(longId); + expect(parseDuration(graphqlMovies[1].duration)).toStrictEqual(parsedLong); + } + /* eslint-enable jest/no-conditional-expect */ + } finally { + await session.close(); + } + }) + )); + }); +}); diff --git a/packages/graphql/tests/schema/types/duration.test.ts b/packages/graphql/tests/schema/types/duration.test.ts new file mode 100644 index 0000000000..5add8c0d31 --- /dev/null +++ b/packages/graphql/tests/schema/types/duration.test.ts @@ -0,0 +1,146 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This movie is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this movie 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 { printSchemaWithDirectives } from "@graphql-tools/utils"; +import { lexicographicSortSchema } from "graphql/utilities"; +import { gql } from "apollo-server"; +import { Neo4jGraphQL } from "../../../src"; + +describe("Duration", () => { + test("Duration", () => { + const typeDefs = gql` + type Movie { + id: ID + duration: Duration + } + `; + const neoSchema = new Neo4jGraphQL({ typeDefs }); + const printedSchema = printSchemaWithDirectives(lexicographicSortSchema(neoSchema.schema)); + + expect(printedSchema).toMatchInlineSnapshot(` + "schema { + query: Query + mutation: Mutation + } + + type CreateInfo { + bookmark: String + nodesCreated: Int! + relationshipsCreated: Int! + } + + type CreateMoviesMutationResponse { + info: CreateInfo! + movies: [Movie!]! + } + + type DeleteInfo { + bookmark: String + nodesDeleted: Int! + relationshipsDeleted: Int! + } + + \\"\\"\\"A duration, represented as an ISO 8601 duration string\\"\\"\\" + scalar Duration + + type Movie { + duration: Duration + id: ID + } + + input MovieCreateInput { + duration: Duration + id: ID + } + + input MovieOptions { + limit: Int + offset: Int + \\"\\"\\"Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.\\"\\"\\" + sort: [MovieSort] + } + + \\"\\"\\"Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.\\"\\"\\" + input MovieSort { + duration: SortDirection + id: SortDirection + } + + input MovieUpdateInput { + duration: Duration + id: ID + } + + input MovieWhere { + AND: [MovieWhere!] + OR: [MovieWhere!] + duration: Duration + duration_GT: Duration + duration_GTE: Duration + duration_IN: [Duration] + duration_LT: Duration + duration_LTE: Duration + duration_NOT: Duration + duration_NOT_IN: [Duration] + id: ID + id_CONTAINS: ID + id_ENDS_WITH: ID + id_IN: [ID] + id_NOT: ID + id_NOT_CONTAINS: ID + id_NOT_ENDS_WITH: ID + id_NOT_IN: [ID] + id_NOT_STARTS_WITH: ID + id_STARTS_WITH: ID + } + + type Mutation { + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere): DeleteInfo! + updateMovies(update: MovieUpdateInput, where: MovieWhere): UpdateMoviesMutationResponse! + } + + type Query { + movies(options: MovieOptions, where: MovieWhere): [Movie!]! + moviesCount(where: MovieWhere): Int! + } + + enum SortDirection { + \\"\\"\\"Sort by field values in ascending order.\\"\\"\\" + ASC + \\"\\"\\"Sort by field values in descending order.\\"\\"\\" + DESC + } + + type UpdateInfo { + bookmark: String + nodesCreated: Int! + nodesDeleted: Int! + relationshipsCreated: Int! + relationshipsDeleted: Int! + } + + type UpdateMoviesMutationResponse { + info: UpdateInfo! + movies: [Movie!]! + } + " + `); + }); +}); diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/types/duration.md b/packages/graphql/tests/tck/tck-test-files/cypher/types/duration.md new file mode 100644 index 0000000000..3b4921ae69 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/types/duration.md @@ -0,0 +1,186 @@ +# Cypher Duration + +Tests Duration operations. ⚠ The string in params is actually an object but the test suite turns it into a string when calling `JSON.stringify`. + +Schema: + +```graphql +type Movie { + id: ID + duration: Duration +} +``` + +--- + +## Simple Read + +### GraphQL Input + +```graphql +query { + movies(where: { duration: "P1Y" }) { + duration + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.duration = $this_duration +RETURN this { .duration } as this +``` + +### Expected Cypher Params + +```json +{ + "this_duration": { + "months": 12, + "days": 0, + "seconds": { + "high": 0, + "low": 0 + }, + "nanoseconds": { + "high": 0, + "low": 0 + } + } +} +``` + +--- + +## GTE Read + +### GraphQL Input + +```graphql +query { + movies(where: { duration_GTE: "P3Y4M" }) { + duration + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE datetime() + this.duration >= datetime() + $this_duration_GTE +RETURN this { .duration } as this +``` + +### Expected Cypher Params + +```json +{ + "this_duration_GTE": { + "months": 40, + "days": 0, + "seconds": { + "low": 0, + "high": 0 + }, + "nanoseconds": { + "low": 0, + "high": 0 + } + } +} +``` + +--- + +## Simple Create + +### GraphQL Input + +```graphql +mutation { + createMovies(input: [{ duration: "P2Y" }]) { + movies { + duration + } + } +} +``` + +### Expected Cypher Output + +```cypher +CALL { + CREATE (this0:Movie) + SET this0.duration = $this0_duration + RETURN this0 +} +RETURN this0 { .duration } AS this0 +``` + +### Expected Cypher Params + +```json +{ + "this0_duration": { + "months": 24, + "days": 0, + "seconds": { + "high": 0, + "low": 0 + }, + "nanoseconds": { + "high": 0, + "low": 0 + } + } +} +``` + +--- + +## Simple Update + +### GraphQL Input + +```graphql +mutation { + updateMovies(update: { duration: "P4D" }) { + movies { + id + duration + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +SET this.duration = $this_update_duration +RETURN this { .id, .duration } AS this +``` + +### Expected Cypher Params + +```json +{ + "this_update_duration": { + "months": 0, + "days": 4, + "seconds": { + "high": 0, + "low": 0 + }, + "nanoseconds": { + "high": 0, + "low": 0 + } + } +} +``` + +---