Skip to content

Commit

Permalink
Feature: Add Duration Scalar (#459)
Browse files Browse the repository at this point in the history
* add: duration scalar

* add: int test for duration scalar

* add: tck tests for duration scalar

* update: documentation for duration scalar

* cleanup: int test

* fix: regex

* add: error thrown on decimal value

* update: documentation about decimal values

* add: validation and parsing tests

* refactor: for readability and performance

* fix: regex to allow full  ISO 8601 duration

* add: duration comparison

* add: {Create,Update}Info and bookmarks

* fix: regex group capture

* add: negative duration for unit based

* cleanup

* fix: documentation

* fix: regex negative unit time

* fix: decimal values for components

* Apply suggestions from code review

Co-authored-by: Darrell Warde <[email protected]>

* fix: readability and unnecessary regex test

* remove: unnessary type check

* add: missing type check

* move: duration schema test

Co-authored-by: Darrell Warde <[email protected]>
Co-authored-by: Oskar Hane <[email protected]>
  • Loading branch information
3 people authored Sep 14, 2021
1 parent b2f639c commit 8896131
Show file tree
Hide file tree
Showing 11 changed files with 1,089 additions and 30 deletions.
16 changes: 16 additions & 0 deletions docs/modules/ROOT/pages/type-definitions/types.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 11 additions & 3 deletions packages/graphql/src/schema/get-where-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
60 changes: 60 additions & 0 deletions packages/graphql/src/schema/scalars/Duration.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof parseDuration>;

describe("Duration Scalar", () => {
test.each<any>([42, () => 5, { a: 3, b: 4 }, null, undefined])("should not match %p and throw error", (value) =>
expect(() => parseDuration(value)).toThrow(TypeError)
);
test.each<string>([
"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)
);
});
149 changes: 149 additions & 0 deletions packages/graphql/src/schema/scalars/Duration.ts
Original file line number Diff line number Diff line change
@@ -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 = /^(?<negated>-?)P(?!$)(?:(?:(?<yearUnit>-?\d+(?:\.\d+(?=Y$))?)Y)?(?:(?<monthUnit>-?\d+(?:\.\d+(?=M$))?)M)?(?:(?<dayUnit>-?\d+(?:\.\d+(?=D$))?)D)?(?:T(?=-?\d)(?:(?<hourUnit>-?\d+(?:\.\d+(?=H$))?)H)?(?:(?<minuteUnit>-?\d+(?:\.\d+(?=M$))?)M)?(?:(?<secondUnit>-?\d+(?:\.\d+(?=S$))?)S)?)?|(?<weekUnit>-?\d+(?:\.\d+)?)W|(?<yearDT>\d{4})(?<dateDelimiter>-?)(?<monthDT>[0]\d|1[0-2])\k<dateDelimiter>(?<dayDT>\d{2})T(?<hourDT>[01]\d|2[0-3])(?<timeDelimiter>(?:(?<=-\w+?):)|(?<=^-?\w+))(?<minuteDT>[0-5]\d)\k<timeDelimiter>(?<secondDT>[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);
},
});
1 change: 1 addition & 0 deletions packages/graphql/src/schema/scalars/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
69 changes: 49 additions & 20 deletions packages/graphql/src/translate/create-where-and-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
Loading

0 comments on commit 8896131

Please sign in to comment.