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."); + } +}