diff --git a/.gitignore b/.gitignore
index 896c23c..9e6b32c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,4 +19,5 @@ coverage
xml-coverage
composer.lock
-.env
\ No newline at end of file
+.env
+.phpunit.result.cache
diff --git a/composer.json b/composer.json
index 8382c50..36e11d3 100644
--- a/composer.json
+++ b/composer.json
@@ -58,4 +58,4 @@
"phpunit-with-coverage" : "XDEBUG_MODE=coverage php -d memory_limit=-1 vendor/bin/phpunit --configuration=phpunitCoverage.xml --testsuite=All --coverage-filter=src tests"
}
-}
+}
\ No newline at end of file
diff --git a/phpUnit.xml b/phpUnit.xml
new file mode 100644
index 0000000..fc18e76
--- /dev/null
+++ b/phpUnit.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+ ./tests
+
+
+
diff --git a/phpunitCoverage.xml b/phpunitCoverage.xml
new file mode 100644
index 0000000..0ad90bd
--- /dev/null
+++ b/phpunitCoverage.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+ tests
+
+
+
+
+ src
+
+
+
+
+
+ src
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OGM.php b/src/OGM.php
index a471e16..b8071f1 100644
--- a/src/OGM.php
+++ b/src/OGM.php
@@ -2,11 +2,19 @@
namespace Neo4j\QueryAPI;
+use DateTimeZone;
use Neo4j\QueryAPI\Objects\Point;
use Neo4j\QueryAPI\Objects\Node;
use Neo4j\QueryAPI\Objects\Relationship;
use Neo4j\QueryAPI\Objects\Path;
use InvalidArgumentException;
+use Neo4j\QueryAPI\Objects\Temporal\Date;
+use Neo4j\QueryAPI\Objects\Temporal\DateTime;
+use Neo4j\QueryAPI\Objects\Temporal\DateTimeZoneId;
+use Neo4j\QueryAPI\Objects\Temporal\Duration;
+use Neo4j\QueryAPI\Objects\Temporal\LocalDateTime;
+use Neo4j\QueryAPI\Objects\Temporal\LocalTime;
+use Neo4j\QueryAPI\Objects\Temporal\Time;
final class OGM
{
@@ -21,7 +29,7 @@ public function map(array $data): mixed
}
return match ($data['$type']) {
- 'Integer', 'Float', 'String', 'Boolean', 'Duration', 'OffsetDateTime' => $data['_value'],
+ 'Integer', 'Float', 'Boolean' => $data['_value'],
'Array', 'List' => is_array($data['_value']) ? array_map([$this, 'map'], $data['_value']) : [],
'Null' => null,
'Node' => $this->mapNode($data['_value']),
@@ -29,6 +37,16 @@ public function map(array $data): mixed
'Point' => $this->parsePoint($data['_value']),
'Relationship' => $this->mapRelationship($data['_value']),
'Path' => $this->mapPath($data['_value']),
+ 'Date' => $this->mapDate($data['_value']),
+ 'OffsetDateTime' => $this->mapDateTime($data['_value']),
+ 'Time' => $this->mapTime($data['_value']),
+ 'LocalTime' => $this->mapLocalTime($data['_value']),
+ 'LocalDateTime' => $this->mapLocalDateTime($data['_value']),
+ 'Duration' => $this->mapDuration($data['_value']),
+
+ 'String' => $this->isValidTimeZone($data['_value'])
+ ? new DateTimeZoneId($data['_value']) // Convert timezone strings to `DateTimeZoneId`
+ : $data['_value'],
default => throw new InvalidArgumentException('Unknown type: ' . json_encode($data, JSON_THROW_ON_ERROR)),
};
}
@@ -37,10 +55,10 @@ public function map(array $data): mixed
private function parsePoint(string $value): Point
{
if (preg_match('/SRID=(\d+);POINT(?: Z)? \(([-\d.]+) ([-\d.]+)(?: ([-\d.]+))?\)/', $value, $matches)) {
- $srid = (int) $matches[1];
- $x = (float) $matches[2];
- $y = (float) $matches[3];
- $z = isset($matches[4]) ? (float) $matches[4] : null;
+ $srid = (int)$matches[1];
+ $x = (float)$matches[2];
+ $y = (float)$matches[3];
+ $z = isset($matches[4]) ? (float)$matches[4] : null;
return new Point($x, $y, $z, $srid);
}
@@ -129,5 +147,46 @@ private function mapProperties(array $properties): array
return $mappedProperties;
}
+ private function mapDate(string $value): Date
+ {
+ $totalDaysSinceEpoch = (new \DateTime($value))->diff(new \DateTime('@0'))->days;
+
+ return new Date($totalDaysSinceEpoch);
+ }
+
+ private function mapDateTime(string $value): DateTime
+ {
+ return new DateTime($value);
+ }
+
+ private function mapDateTimeZoneId(string $value): DateTimeZoneId
+ {
+ return new DateTimeZoneId($value);
+ }
+
+ private function isValidTimeZone(string $value): bool
+ {
+ return in_array($value, timezone_identifiers_list(), true);
+ }
+ private function mapTime(mixed $_value): Time
+ {
+ return new Time($_value);
+
+ }
+
+ private function mapLocalTime(mixed $_value): LocalTime
+ {
+ return new LocalTime($_value);
+ }
+
+ private function mapLocalDateTime(mixed $_value): LocalDateTime
+ {
+ return new LocalDateTime($_value);
+ }
+
+ private function mapDuration(mixed $_value): Duration
+ {
+ return new Duration($_value);
+ }
}
diff --git a/src/Objects/Temporal/Date.php b/src/Objects/Temporal/Date.php
new file mode 100644
index 0000000..7e9bf2a
--- /dev/null
+++ b/src/Objects/Temporal/Date.php
@@ -0,0 +1,35 @@
+days = $days;
+ }
+
+ /**
+ * Returns the number of days since the Unix epoch.
+ */
+ public function getDays(): int
+ {
+ return $this->days;
+ }
+
+ /**
+ * Converts the stored date into a DateTimeImmutable.
+ */
+ public function toDateTimeImmutable(): \DateTimeImmutable
+ {
+ $dt = new \DateTimeImmutable('@0');
+ return $dt->modify(sprintf('+%d days', $this->days));
+ }
+
+ public function __toString(): string
+ {
+ return sprintf("Date(%d days since epoch)", $this->days);
+ }
+}
diff --git a/src/Objects/Temporal/DateTime.php b/src/Objects/Temporal/DateTime.php
new file mode 100644
index 0000000..5e04c05
--- /dev/null
+++ b/src/Objects/Temporal/DateTime.php
@@ -0,0 +1,23 @@
+dateTime = new \DateTimeImmutable($dateTime);
+ }
+
+ public function getDateTime(): \DateTimeImmutable
+ {
+ return $this->dateTime;
+ }
+
+ public function __toString(): string
+ {
+ return $this->dateTime->format('c');
+ }
+}
diff --git a/src/Objects/Temporal/DateTimeZoneId.php b/src/Objects/Temporal/DateTimeZoneId.php
new file mode 100644
index 0000000..5559b4e
--- /dev/null
+++ b/src/Objects/Temporal/DateTimeZoneId.php
@@ -0,0 +1,23 @@
+zoneId = $zoneId;
+ }
+
+ public function getZoneId(): string
+ {
+ return $this->zoneId;
+ }
+
+ public function __toString(): string
+ {
+ return $this->zoneId;
+ }
+}
diff --git a/src/Objects/Temporal/Duration.php b/src/Objects/Temporal/Duration.php
new file mode 100644
index 0000000..e1f729e
--- /dev/null
+++ b/src/Objects/Temporal/Duration.php
@@ -0,0 +1,23 @@
+duration = $duration;
+ }
+
+ public function getDuration(): string
+ {
+ return $this->duration;
+ }
+
+ public function __toString(): string
+ {
+ return $this->duration;
+ }
+}
diff --git a/src/Objects/Temporal/LocalDateTime.php b/src/Objects/Temporal/LocalDateTime.php
new file mode 100644
index 0000000..3727220
--- /dev/null
+++ b/src/Objects/Temporal/LocalDateTime.php
@@ -0,0 +1,24 @@
+localDateTime = new \DateTimeImmutable($localDateTime);
+ }
+
+ public function getLocalDateTime(): \DateTimeImmutable
+ {
+ return $this->localDateTime;
+ }
+
+ public function __toString(): string
+ {
+ // Adjust the format as necessary (without timezone info)
+ return $this->localDateTime->format('Y-m-d\TH:i:s');
+ }
+}
diff --git a/src/Objects/Temporal/LocalTime.php b/src/Objects/Temporal/LocalTime.php
new file mode 100644
index 0000000..0da4147
--- /dev/null
+++ b/src/Objects/Temporal/LocalTime.php
@@ -0,0 +1,23 @@
+localTime = $localTime;
+ }
+
+ public function getLocalTime(): string
+ {
+ return $this->localTime;
+ }
+
+ public function __toString(): string
+ {
+ return $this->localTime;
+ }
+}
diff --git a/src/Objects/Temporal/Time.php b/src/Objects/Temporal/Time.php
new file mode 100644
index 0000000..dd41b23
--- /dev/null
+++ b/src/Objects/Temporal/Time.php
@@ -0,0 +1,23 @@
+time = $time;
+ }
+
+ public function getTime(): string
+ {
+ return $this->time;
+ }
+
+ public function __toString(): string
+ {
+ return $this->time;
+ }
+}
diff --git a/src/Results/ResultRow.php b/src/Results/ResultRow.php
index 9e2fa67..5d50fee 100644
--- a/src/Results/ResultRow.php
+++ b/src/Results/ResultRow.php
@@ -18,7 +18,7 @@
final class ResultRow implements ArrayAccess, Countable, IteratorAggregate
{
/** @var array */
- private array $data;
+ public array $data;
public function __construct(array $data)
{
diff --git a/src/Results/ResultSet.php b/src/Results/ResultSet.php
index 9a373e0..07ad7a0 100644
--- a/src/Results/ResultSet.php
+++ b/src/Results/ResultSet.php
@@ -2,6 +2,7 @@
namespace Neo4j\QueryAPI\Results;
+use ArrayAccess;
use ArrayIterator;
use Countable;
use IteratorAggregate;
@@ -9,22 +10,24 @@
use Neo4j\QueryAPI\Objects\Bookmarks;
use Neo4j\QueryAPI\Objects\ProfiledQueryPlan;
use Neo4j\QueryAPI\Objects\ResultCounters;
+use OutOfBoundsException;
use Traversable;
/**
* @template TValue
+ * @implements ArrayAccess
* @implements IteratorAggregate
*/
-final class ResultSet implements IteratorAggregate, Countable
+final class ResultSet implements IteratorAggregate, Countable, ArrayAccess
{
/**
* @param list $rows
*/
public function __construct(
- public readonly array $rows,
- public readonly Bookmarks $bookmarks,
- public readonly AccessMode $accessMode,
- public readonly ?ResultCounters $counters = null,
+ public readonly array $rows,
+ public readonly Bookmarks $bookmarks,
+ public readonly AccessMode $accessMode,
+ public readonly ?ResultCounters $counters = null,
public readonly ?ProfiledQueryPlan $profiledQueryPlan = null
) {
}
@@ -45,4 +48,36 @@ public function count(): int
return count($this->rows);
}
+ public function get(int $index): ResultRow
+ {
+ if (!isset($this->rows[$index])) {
+ throw new OutOfBoundsException('Index ' . $index . ' does not exist');
+ }
+ return $this->rows[$index];
+
+ }
+ #[\Override]
+
+ public function offsetExists(mixed $offset): bool
+ {
+ return isset($this->rows[$offset]);
+ }
+ #[\Override]
+
+ public function offsetGet(mixed $offset): mixed
+ {
+ return $this->rows[$offset] ?? throw new \OutOfBoundsException("Index $offset is out of bounds.");
+ }
+ #[\Override]
+
+ public function offsetSet(mixed $offset, mixed $value): void
+ {
+ throw new \LogicException("ResultSet is immutable. You cannot modify elements.");
+ }
+ #[\Override]
+
+ public function offsetUnset(mixed $offset): void
+ {
+ throw new \LogicException("ResultSet is immutable. You cannot remove elements.");
+ }
}
diff --git a/tests/Integration/DataTypesIntegrationTest.php b/tests/Integration/DataTypesIntegrationTest.php
index 81c8297..2bc9ccf 100644
--- a/tests/Integration/DataTypesIntegrationTest.php
+++ b/tests/Integration/DataTypesIntegrationTest.php
@@ -238,34 +238,6 @@ public function testWithArray(): void
}
- public function testWithDate(): void
- {
- $expected = new ResultSet(
- [
- new ResultRow(['n.date' => '2024-12-11T11:00:00Z'])
- ],
- new Bookmarks([]),
- AccessMode::WRITE,
- new ResultCounters(
- containsUpdates: true,
- nodesCreated: 1,
- propertiesSet: 1,
- labelsAdded: 1
- ),
- null
- );
-
- $results = $this->api->run(
- 'CREATE (n:Person {date: datetime($date)}) RETURN n.date',
- ['date' => "2024-12-11T11:00:00Z"]
- );
-
- $this->assertEquals($expected->counters, $results->counters);
- $this->assertEquals(iterator_to_array($expected), iterator_to_array($results));
- $bookmarks = $results->bookmarks;
- $this->assertCount(1, $bookmarks);
- }
-
public function testWithDuration(): void
{
$expected = new ResultSet(
diff --git a/tests/Integration/Neo4jQueryAPIIntegrationTest.php b/tests/Integration/Neo4jQueryAPIIntegrationTest.php
index 490d657..3dc9cf3 100644
--- a/tests/Integration/Neo4jQueryAPIIntegrationTest.php
+++ b/tests/Integration/Neo4jQueryAPIIntegrationTest.php
@@ -2,22 +2,31 @@
namespace Neo4j\QueryAPI\Tests\Integration;
+use DateTimeZone;
use Neo4j\QueryAPI\Exception\Neo4jException;
use Neo4j\QueryAPI\Neo4jQueryAPI;
use Neo4j\QueryAPI\Objects\Authentication;
use Neo4j\QueryAPI\Objects\Node;
use Neo4j\QueryAPI\Objects\Bookmarks;
use Neo4j\QueryAPI\Objects\ResultCounters;
+use Neo4j\QueryAPI\Objects\Temporal\Date;
+use Neo4j\QueryAPI\Objects\Temporal\DateTime;
+use Neo4j\QueryAPI\Objects\Temporal\DateTimeZoneId;
+use Neo4j\QueryAPI\Objects\Temporal\Duration;
+use Neo4j\QueryAPI\Objects\Temporal\LocalDateTime;
+use Neo4j\QueryAPI\Objects\Temporal\LocalTime;
+use Neo4j\QueryAPI\Objects\Temporal\Time;
use Neo4j\QueryAPI\Results\ResultRow;
use Neo4j\QueryAPI\Results\ResultSet;
use PHPUnit\Framework\TestCase;
use Neo4j\QueryAPI\Enums\AccessMode;
+use Throwable;
final class Neo4jQueryAPIIntegrationTest extends TestCase
{
private Neo4jQueryAPI $api;
-
#[\Override]
+
public function setUp(): void
{
parent::setUp();
@@ -66,6 +75,7 @@ private function initializeApi(): Neo4jQueryAPI
}
return Neo4jQueryAPI::login($address, Authentication::fromEnvironment());
}
+
public function testCounters(): void
{
$result = $this->api->run('CREATE (x:Node {hello: "world"})');
@@ -94,11 +104,128 @@ public function testInvalidQueryException(): void
$this->api->run('CREATE (:Person {createdAt: $invalidParam})', [
'date' => new \DateTime('2000-01-01 00:00:00')
]);
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
$this->assertInstanceOf(Neo4jException::class, $e);
$this->assertEquals('Neo.ClientError.Statement.ParameterMissing', $e->getErrorCode());
$this->assertEquals('Expected parameter(s): invalidParam', $e->getMessage());
}
}
+ public function testTemporalDate(): void
+ {
+ $results = $this->api->run('RETURN date() AS date');
+
+ $this->assertNotEmpty($results->rows);
+ $this->assertInstanceOf(ResultRow::class, $results->rows[0]);
+ $this->assertArrayHasKey('date', $results->rows[0]->data);
+
+ $date = $results->rows[0]->data['date'];
+ $this->assertInstanceOf(Date::class, $date);
+
+ $expectedDays = (new \DateTime("1970-01-01"))->diff(new \DateTime())->format('%r%a');
+ $this->assertEquals((int)$expectedDays, $date->days);
+ }
+
+ public function testTemporalDateTime(): void
+ {
+ $results = $this->api->run('RETURN DateTime() AS datetime');
+
+ $this->assertNotEmpty($results->rows);
+ $this->assertInstanceOf(ResultRow::class, $results->rows[0]);
+ $this->assertArrayHasKey('datetime', $results->rows[0]->data);
+ $datetime = $results->rows[0]->data['datetime'];
+ $this->assertInstanceOf(DateTime::class, $datetime);
+
+ $now = date_create();
+ $this->assertNotFalse($now);
+ $this->assertEquals($now->format('Y-m-d'), $datetime->getDateTime()->format('Y-m-d'));
+ }
+
+
+ public function testTemporalDateTimeZoneId(): void
+ {
+ $results = $this->api->run("RETURN 'America/New_York' AS timezone");
+
+ $this->assertNotEmpty($results->rows);
+ $this->assertInstanceOf(ResultRow::class, $results->rows[0]);
+ $this->assertArrayHasKey('timezone', $results->rows[0]->data);
+ $this->assertInstanceOf(DateTimeZoneId::class, $results->rows[0]->data['timezone']);
+
+ $zoneId = $results->rows[0]->data['timezone']->getZoneId();
+ $this->assertEquals('America/New_York', $zoneId);
+ }
+
+
+ public function testTemporalTime(): void
+ {
+ $results = $this->api->run('RETURN time() AS time');
+
+ $this->assertNotEmpty($results->rows);
+ $this->assertInstanceOf(ResultRow::class, $results->rows[0]);
+ $this->assertArrayHasKey('time', $results->rows[0]->data);
+ $time = $results->rows[0]->data['time'];
+ $this->assertInstanceOf(Time::class, $time);
+
+ $neo4jTime = $time->getTime();
+ $expectedTime = (new \DateTime())->format('H:i');
+ $this->assertNotEmpty($expectedTime);
+
+ $this->assertStringStartsWith($expectedTime, $neo4jTime);
+ }
+
+ public function testTemporalLocalTime(): void
+ {
+ $results = $this->api->run('RETURN localtime() AS localtime');
+
+ $this->assertNotEmpty($results->rows);
+ $this->assertInstanceOf(ResultRow::class, $results->rows[0]);
+ $this->assertArrayHasKey('localtime', $results->rows[0]->data);
+ $localTime = $results->rows[0]->data['localtime'];
+ $this->assertInstanceOf(LocalTime::class, $localTime);
+ $neo4jLocalTime = $localTime->getLocalTime();
+
+ $expectedLocalTime = (new \DateTime())->format('H:i');
+ $this->assertNotEmpty($expectedLocalTime);
+
+ $this->assertStringStartsWith($expectedLocalTime, $neo4jLocalTime);
+ }
+
+ public function testTemporalLocalDateTime(): void
+ {
+ $results = $this->api->run('RETURN localdatetime() AS localdatetime');
+
+ $this->assertNotEmpty($results->rows);
+ $this->assertInstanceOf(ResultRow::class, $results->rows[0]);
+ $this->assertArrayHasKey('localdatetime', $results->rows[0]->data);
+ $localDateTime = $results->rows[0]->data['localdatetime'];
+
+ $this->assertInstanceOf(LocalDateTime::class, $localDateTime);
+
+ $neo4jLocalDateTime = $localDateTime->getLocalDateTime();
+
+ $expectedDateTime = date_create();
+ $this->assertNotFalse($expectedDateTime); // 💡 Ensure it's not false
+
+ $expectedDate = $expectedDateTime->format('Y-m-d');
+ $this->assertNotEmpty($expectedDate);
+
+ $this->assertEquals($expectedDate, $neo4jLocalDateTime->format('Y-m-d'));
+ }
+
+ public function testTemporalDuration(): void
+ {
+ $results = $this->api->run("RETURN duration({years: 1, months: 2, days: 10, hours: 5, minutes: 30, seconds: 15}) AS duration");
+
+ $this->assertNotEmpty($results->rows);
+ $this->assertInstanceOf(ResultRow::class, $results->rows[0]);
+ $this->assertArrayHasKey('duration', $results->rows[0]->data);
+ $duration = $results->rows[0]->data['duration'];
+ $this->assertInstanceOf(Duration::class, $duration);
+ $neo4jDuration = $duration->getDuration();
+
+ $expectedPattern = '/P1Y2M10DT5H30M15S/';
+
+ $this->assertMatchesRegularExpression($expectedPattern, $neo4jDuration);
+ }
+
}
diff --git a/tests/Unit/Temporal/OGMTemporalTest.php b/tests/Unit/Temporal/OGMTemporalTest.php
new file mode 100644
index 0000000..77a43ee
--- /dev/null
+++ b/tests/Unit/Temporal/OGMTemporalTest.php
@@ -0,0 +1,36 @@
+ogm = new OGM();
+ }
+
+ public function testConvertDate(): void
+ {
+ $data = [
+ '$type' => 'Date',
+ '_value' => '2023-03-20'
+ ];
+
+ $result = $this->ogm->map($data);
+
+ $this->assertInstanceOf(Date::class, $result, "The result should be an instance of Date");
+
+ $dateTime = new \DateTimeImmutable('2023-03-20');
+ $expectedDays = (int) floor($dateTime->getTimestamp() / 86400);
+
+ $this->assertEquals($expectedDays, $result->getDays(), "The calculated days should match the expected value.");
+ }
+}