diff --git a/docs/asciidoc/modules/ROOT/pages/overview/apoc.temporal/apoc.temporal.overlap.adoc b/docs/asciidoc/modules/ROOT/pages/overview/apoc.temporal/apoc.temporal.overlap.adoc new file mode 100644 index 0000000000..76b8767cec --- /dev/null +++ b/docs/asciidoc/modules/ROOT/pages/overview/apoc.temporal/apoc.temporal.overlap.adoc @@ -0,0 +1,43 @@ += apoc.temporal.overlap +:description: This section contains reference documentation for the apoc.temporal.overlap function. + +label:function[] label:apoc-extended[] + +[.emphasis] +apoc.temporal.overlap(start1,end1,start2,end2,$config) - Check whether the two temporal spans (start1-end1 and start2-end2) overlap or not + +== Signature + +[source] +---- +apoc.temporal.overlap(start1 :: ANY?, end1 :: ANY?, start2 :: ANY?, end2 :: ANY?, config = {} :: MAP?) :: BOOLEAN? +---- + +== Input parameters +[.procedures, opts=header] +|=== +| Name | Type | Default +|start1|ANY?|null +|end1|ANY?|null +|start2|ANY?|null +|end2|ANY?|null +|config|MAP?|{} +|=== + + +== Config parameters + +The function support the following config parameters: + +.Config parameters +[opts=header] +|=== +| name | type | default | description +| acceptAdjacentSpans | boolean | false | also considers adjacent spans +|=== + +[[usage-apoc.temporal.overlap]] +== Usage Examples +include::partial$usage/apoc.temporal.overlap.adoc[] + + diff --git a/docs/asciidoc/modules/ROOT/pages/overview/apoc.temporal/index.adoc b/docs/asciidoc/modules/ROOT/pages/overview/apoc.temporal/index.adoc new file mode 100644 index 0000000000..f0ec07db87 --- /dev/null +++ b/docs/asciidoc/modules/ROOT/pages/overview/apoc.temporal/index.adoc @@ -0,0 +1,10 @@ += apoc.temporal +:description: This section contains reference documentation for the apoc.temporal procedures. + +[.procedures, opts=header, cols='5a,1a'] +|=== +| Qualified Name | Type +|xref::overview/apoc.temporal/apoc.temporal.overlap.adoc[apoc.temporal.overlap icon:book[]] + +|label:function[] +|=== diff --git a/docs/asciidoc/modules/ROOT/partials/generated-documentation/documentation.adoc b/docs/asciidoc/modules/ROOT/partials/generated-documentation/documentation.adoc index dbacdf391e..57e698a815 100644 --- a/docs/asciidoc/modules/ROOT/partials/generated-documentation/documentation.adoc +++ b/docs/asciidoc/modules/ROOT/partials/generated-documentation/documentation.adoc @@ -809,6 +809,15 @@ apoc.static.getAll(prefix) - returns statically stored values from config (apoc. |label:procedure[] |=== +== xref::overview/apoc.temporal/index.adoc[] +[.procedures, opts=header, cols='5a,1a'] +|=== +| Qualified Name | Type +|xref::overview/apoc.temporal/apoc.temporal.overlap.adoc[apoc.temporal.overlap icon:book[]] + +|label:function[] +|=== + == xref::overview/apoc.trigger/index.adoc[] [.procedures, opts=header, cols='5a,1a'] diff --git a/docs/asciidoc/modules/ROOT/partials/generated-documentation/nav.adoc b/docs/asciidoc/modules/ROOT/partials/generated-documentation/nav.adoc index 02f07fec92..4f0dbf0c0e 100644 --- a/docs/asciidoc/modules/ROOT/partials/generated-documentation/nav.adoc +++ b/docs/asciidoc/modules/ROOT/partials/generated-documentation/nav.adoc @@ -163,6 +163,8 @@ This file is generated by DocsTest, so don't change it! *** xref::overview/apoc.systemdb/apoc.systemdb.execute.adoc[] *** xref::overview/apoc.systemdb/apoc.systemdb.export.metadata.adoc[] *** xref::overview/apoc.systemdb/apoc.systemdb.graph.adoc[] +** xref::overview/apoc.temporal/index.adoc[] +*** xref::overview/apoc.temporal/apoc.temporal.overlap.adoc[] ** xref::overview/apoc.trigger/index.adoc[] *** xref::overview/apoc.trigger/apoc.trigger.nodesByLabel.adoc[] *** xref::overview/apoc.trigger/apoc.trigger.propertiesByKey.adoc[] diff --git a/docs/asciidoc/modules/ROOT/partials/usage/apoc.temporal.overlap.adoc b/docs/asciidoc/modules/ROOT/partials/usage/apoc.temporal.overlap.adoc new file mode 100644 index 0000000000..9c566fbc0f --- /dev/null +++ b/docs/asciidoc/modules/ROOT/partials/usage/apoc.temporal.overlap.adoc @@ -0,0 +1,74 @@ +.adjacent spans with default config +[source,cypher] +---- +RETURN apoc.temporal.overlap( + date("1999"), + date("2000"), + date("2000"), + date("2001")) +AS value +---- + +.Results +[opts="header"] +|=== +| value +| false +|=== + + +.adjacent spans with config acceptAdjacentSpans: true +[source,cypher] +---- +RETURN apoc.temporal.overlap( + date("1999"), + date("2000"), + date("2000"), + date("2001"), + {acceptAdjacentSpans: true} ) +AS value +---- + +.Results +[opts="header"] +|=== +| value +| true +|=== + +.duration spans +[source,cypher] +---- +RETURN apoc.temporal.overlap( + time("00:01"), + time("01:01"), + time("00:00"), + time("00:02") ) +AS value +---- + +.Results +[opts="header"] +|=== +| value +| true +|=== + + +.non-comparable spans +[source,cypher] +---- +RETURN apoc.temporal.overlap( + date("1998"), + date("1999"), + time("00:00"), + time("00:02") ) +AS value +---- + +.Results +[opts="header"] +|=== +| value +| null +|=== \ No newline at end of file diff --git a/extended/src/main/java/apoc/temporal/TemporalExtended.java b/extended/src/main/java/apoc/temporal/TemporalExtended.java new file mode 100644 index 0000000000..eb1729bebe --- /dev/null +++ b/extended/src/main/java/apoc/temporal/TemporalExtended.java @@ -0,0 +1,58 @@ +package apoc.temporal; + +import apoc.Extended; +import apoc.util.Util; +import org.neo4j.graphdb.Transaction; +import org.neo4j.kernel.impl.util.ValueUtils; +import org.neo4j.procedure.Context; +import org.neo4j.procedure.Description; +import org.neo4j.procedure.Name; +import org.neo4j.procedure.UserFunction; +import org.neo4j.values.AnyValue; +import org.neo4j.values.Comparison; + +import java.util.Map; + +import static org.neo4j.values.AnyValues.TERNARY_COMPARATOR; + +@Extended +public class TemporalExtended { + public static final String ACCEPT_ADJACENT_KEY = "acceptAdjacentSpans"; + + @Context + public Transaction tx; + + @UserFunction("apoc.temporal.overlap") + @Description("apoc.temporal.overlap(start1,end1,start2,end2,$config) - Check whether the two temporal spans (start1-end1 and start2-end2) overlap or not") + public Boolean overlap(@Name("start1") Object start1, + @Name("end1") Object end1, + @Name("start2") Object start2, + @Name("end2") Object end2, + @Name(value = "config", defaultValue = "{}") Map config) { + + boolean acceptAdjacentSpans = Util.toBoolean(config.get(ACCEPT_ADJACENT_KEY)); + + AnyValue startValue1 = ValueUtils.of(start1); + AnyValue endValue2 = ValueUtils.of(end2); + + AnyValue startValue2 = ValueUtils.of(start2); + AnyValue endValue1 = ValueUtils.of(end1); + + // The overlap formula is: `(start1 <= end2) && (start2 <= end1)` + Comparison comparisonLeft = TERNARY_COMPARATOR.ternaryCompare(startValue1, endValue2); + Comparison comparisonRight = TERNARY_COMPARATOR.ternaryCompare(startValue2, endValue1); + + // if object are incompatibles, the Comparison returns UNDEFINED + if (comparisonLeft.equals(Comparison.UNDEFINED) || comparisonRight.equals(Comparison.UNDEFINED)) { + return null; + } + + int rangeLeft = comparisonLeft.value(); + int rangeRight = comparisonRight.value(); + if (acceptAdjacentSpans) { + return rangeLeft <= 0 && rangeRight <= 0; + } + return rangeLeft < 0 && rangeRight < 0; + } + +} diff --git a/extended/src/main/resources/extended.txt b/extended/src/main/resources/extended.txt index d344975395..ff57fafbdd 100644 --- a/extended/src/main/resources/extended.txt +++ b/extended/src/main/resources/extended.txt @@ -186,6 +186,7 @@ apoc.static.set apoc.systemdb.execute apoc.systemdb.export.metadata apoc.systemdb.graph +apoc.temporal.overlap apoc.ttl.expire apoc.ttl.expireIn apoc.util.hashCode diff --git a/extended/src/test/java/apoc/temporal/TemporalExtendedTest.java b/extended/src/test/java/apoc/temporal/TemporalExtendedTest.java new file mode 100644 index 0000000000..5fe824423c --- /dev/null +++ b/extended/src/test/java/apoc/temporal/TemporalExtendedTest.java @@ -0,0 +1,139 @@ +package apoc.temporal; + +import apoc.Extended; +import apoc.util.TestUtil; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.neo4j.test.rule.DbmsRule; +import org.neo4j.test.rule.ImpermanentDbmsRule; +import org.neo4j.values.storable.DateTimeValue; +import org.neo4j.values.storable.DateValue; +import org.neo4j.values.storable.LocalDateTimeValue; +import org.neo4j.values.storable.LocalTimeValue; +import org.neo4j.values.storable.TimeValue; + +import java.time.ZoneId; +import java.util.Map; + +import static apoc.temporal.TemporalExtended.ACCEPT_ADJACENT_KEY; +import static apoc.util.TestUtil.singleResultFirstColumn; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +@Extended +public class TemporalExtendedTest { + + public static final String RETURN_OVERLAP = "RETURN apoc.temporal.overlap($start1, $end1, $start2, $end2, $conf)"; + + @ClassRule + public static DbmsRule db = new ImpermanentDbmsRule(); + + @BeforeClass + public static void setUp() { + TestUtil.registerProcedure(db, TemporalExtended.class); + } + + @Test + public void testOverlapDates() { + Map params = Map.of("start1", DateValue.parse("1999"), + "end1", DateValue.parse("2000"), + "start2", DateValue.parse("2000"), + "end2", DateValue.parse("2001"), + "conf", Map.of()); + + boolean output = singleResultFirstColumn(db, RETURN_OVERLAP, params); + assertFalse(output); + } + + @Test + public void testOverlapDatesWithConfigAcceptAdjacentSpansConf() { + Map params = Map.of("start1", DateValue.parse("1999"), + "end1", DateValue.parse("2000"), + "start2", DateValue.parse("2000"), + "end2", DateValue.parse("2001"), + "conf", Map.of(ACCEPT_ADJACENT_KEY, true)); + + boolean output = singleResultFirstColumn(db, RETURN_OVERLAP, params); + assertTrue(output); + } + + @Test + public void testOverlapWithDatetime() { + Map params = Map.of("start1", DateTimeValue.parse("1999", ZoneId::systemDefault), + "end1", DateTimeValue.parse("2000", ZoneId::systemDefault), + "start2", DateTimeValue.parse("2000", ZoneId::systemDefault), + "end2", DateTimeValue.parse("2001", ZoneId::systemDefault), + "conf", Map.of()); + + boolean output = singleResultFirstColumn(db, RETURN_OVERLAP, params); + assertFalse(output); + } + + @Test + public void testOverlapWithDatetimeAndAcceptAdjacentSpansConf() { + Map params = Map.of("start1", DateTimeValue.parse("1999", ZoneId::systemDefault), + "end1", DateTimeValue.parse("2000", ZoneId::systemDefault), + "start2", DateTimeValue.parse("2000", ZoneId::systemDefault), + "end2", DateTimeValue.parse("2001", ZoneId::systemDefault), + "conf", Map.of(ACCEPT_ADJACENT_KEY, true)); + + boolean output = singleResultFirstColumn(db, RETURN_OVERLAP, params); + assertTrue(output); + } + + @Test + public void testOverlapWithTime() { + Map params = Map.of("start1", TimeValue.parse("00:01", ZoneId::systemDefault), + "end1", TimeValue.parse("01:01", ZoneId::systemDefault), + "start2", TimeValue.parse("00:00", ZoneId::systemDefault), + "end2", TimeValue.parse("00:02", ZoneId::systemDefault), + "conf", Map.of()); + + boolean output = singleResultFirstColumn(db, RETURN_OVERLAP, params); + assertTrue(output); + } + + @Test + public void testOverlapWithLocalTime() { + Map params = Map.of("start1", LocalTimeValue.parse("00:01"), + "end1", LocalTimeValue.parse("01:01"), + "start2", LocalTimeValue.parse("00:00"), + "end2", LocalTimeValue.parse("00:02"), + "conf", Map.of(ACCEPT_ADJACENT_KEY, true)); + + boolean output = singleResultFirstColumn(db, RETURN_OVERLAP, params); + assertTrue(output); + } + + @Test + public void testOverlapWithLocalDateTime() { + Map params = Map.of("start1", LocalDateTimeValue.parse("1999"), + "end1", LocalDateTimeValue.parse("2000"), + "start2", LocalDateTimeValue.parse("2000"), + "end2", LocalDateTimeValue.parse("2001"), + "conf", Map.of()); + + boolean output = singleResultFirstColumn(db, RETURN_OVERLAP, params); + assertFalse(output); + } + + /** + * In this test case the 2 ranges have different types (i.e. `date` and `time`), + * and we just return `null`, + * to be consistent with Cypher's behavior (e.g., `return date("1999") > time("19:00")` has result `null` ) + */ + @Test + public void testOverlapWithWrongTypes() { + Map params = Map.of("start1", DateValue.parse("1999"), + "end1", DateValue.parse("2000"), + "start2", LocalTimeValue.parse("19:00"), + "end2", LocalTimeValue.parse("20:00"), + "conf", Map.of()); + + Object output = singleResultFirstColumn(db, RETURN_OVERLAP, params); + assertNull(output); + } + +}