Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Add Duration Scalar #459

Merged
merged 33 commits into from
Sep 14, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
fcc3af0
add: duration scalar
dmoree Aug 24, 2021
dbd8d2d
add: int test for duration scalar
dmoree Aug 24, 2021
99a9100
add: tck tests for duration scalar
dmoree Aug 24, 2021
396edd4
update: documentation for duration scalar
dmoree Aug 24, 2021
2e98e7e
cleanup: int test
dmoree Aug 24, 2021
3104a7f
fix: regex
dmoree Aug 25, 2021
8b70e0e
add: error thrown on decimal value
dmoree Aug 25, 2021
429ca10
update: documentation about decimal values
dmoree Aug 25, 2021
8f29c06
add: validation and parsing tests
dmoree Aug 25, 2021
4ca8afe
refactor: for readability and performance
dmoree Aug 25, 2021
b79050e
Merge branch 'master' into feature/duration-scalar
darrellwarde Aug 25, 2021
6c18699
Merge branch 'master' into feature/duration-scalar
darrellwarde Aug 25, 2021
d4ca2d1
fix: regex to allow full ISO 8601 duration
dmoree Aug 27, 2021
831c065
add: duration comparison
dmoree Aug 27, 2021
93d303d
Merge branch 'feature/duration-scalar' of https://github.com/dmoree/g…
dmoree Aug 27, 2021
42e6528
add: {Create,Update}Info and bookmarks
dmoree Aug 27, 2021
99980ed
fix: regex group capture
dmoree Aug 27, 2021
e51751c
add: negative duration for unit based
dmoree Aug 27, 2021
e9a5c0f
cleanup
dmoree Aug 29, 2021
cbfa293
fix: documentation
dmoree Aug 29, 2021
d93334d
fix: regex negative unit time
dmoree Aug 31, 2021
d44b7ce
fix: decimal values for components
dmoree Aug 31, 2021
a43a9d6
Merge branch 'master' into feature/duration-scalar
darrellwarde Sep 6, 2021
f7b97d8
Apply suggestions from code review
dmoree Sep 7, 2021
4663bdf
fix: readability and unnecessary regex test
dmoree Sep 7, 2021
273c26b
remove: unnessary type check
dmoree Sep 7, 2021
38c291e
Merge branch 'master' into feature/duration-scalar
darrellwarde Sep 7, 2021
0d7f83f
add: missing type check
dmoree Sep 7, 2021
cb2c4d5
Merge branch 'master' into feature/duration-scalar
darrellwarde Sep 8, 2021
7ab4df1
Merge branch 'master' into feature/duration-scalar
oskarhane Sep 13, 2021
a0538a6
Merge branch 'master' into feature/duration-scalar
darrellwarde Sep 13, 2021
8a4600f
move: duration schema test
dmoree Sep 13, 2021
8823fc4
Merge branch 'master' into feature/duration-scalar
oskarhane Sep 14, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

value is always a string in parseValue, so you can stipulate that here and get rid of the first type check in parseDuration.

Copy link
Contributor Author

@dmoree dmoree Sep 7, 2021

Choose a reason for hiding this comment

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

It's JSON correct? It makes sense that this is a string, but I was relying of graphql which types this as any. I thought I was missing something.

Fixed in 273c26b

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh damn, looking back, maybe I'm the one who's missing something! 🙈 Honestly, I was basing it off the code in this project, for example the BigInt scalar: https://github.com/neo4j/graphql/blob/master/packages/graphql/src/schema/scalars/BigInt.ts#L30

But delving into it, perhaps it should be typed as any, my mistake. 😞 First day back after a week off yesterday, can I be excused? 😄

Definitely remove the type check as you have done in parseDuration, but you're right, it probably does need to be in here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No worries. Added back in 0d7f83f

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