laudis-technologies/neo4j-php-client

View on GitHub
src/Formatter/Specialised/JoltHttpOGMTranslator.php

Summary

Maintainability
A
0 mins
Test Coverage
<?php

declare(strict_types=1);

/*
 * This file is part of the Neo4j PHP Client and Driver package.
 *
 * (c) Nagels <https://nagels.tech>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Laudis\Neo4j\Formatter\Specialised;

use Closure;

use const DATE_ATOM;

use DateInterval;
use DateTimeImmutable;

use function is_array;

use Laudis\Neo4j\Contracts\ConnectionInterface;
use Laudis\Neo4j\Contracts\PointInterface;
use Laudis\Neo4j\Formatter\OGMFormatter;
use Laudis\Neo4j\Http\HttpHelper;
use Laudis\Neo4j\Types\Cartesian3DPoint;
use Laudis\Neo4j\Types\CartesianPoint;
use Laudis\Neo4j\Types\CypherList;
use Laudis\Neo4j\Types\CypherMap;
use Laudis\Neo4j\Types\Date;
use Laudis\Neo4j\Types\DateTime;
use Laudis\Neo4j\Types\Duration;
use Laudis\Neo4j\Types\LocalDateTime;
use Laudis\Neo4j\Types\LocalTime;
use Laudis\Neo4j\Types\Node;
use Laudis\Neo4j\Types\Path;
use Laudis\Neo4j\Types\Relationship;
use Laudis\Neo4j\Types\Time;
use Laudis\Neo4j\Types\UnboundRelationship;
use Laudis\Neo4j\Types\WGS843DPoint;
use Laudis\Neo4j\Types\WGS84Point;

use function preg_match;

use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use RuntimeException;
use stdClass;

use function str_pad;

use const STR_PAD_RIGHT;

use function str_replace;
use function str_starts_with;
use function strtolower;

use UnexpectedValueException;

/**
 * @psalm-immutable
 *
 * @psalm-import-type OGMTypes from OGMFormatter
 *
 * @psalm-suppress PossiblyUndefinedArrayOffset
 */
final class JoltHttpOGMTranslator
{
    /** @var array<string, pure-callable(mixed):OGMTypes> */
    private array $rawToTypes;

    public function __construct()
    {
        /** @psalm-suppress InvalidPropertyAssignmentValue */
        $this->rawToTypes = [
            '?' => static fn (string $value): bool => strtolower($value) === 'true',
            'Z' => static fn (string $value): int => (int) $value,
            'R' => static fn (string $value): float => (float) $value,
            'U' => static fn (string $value): string => $value,
            'T' => Closure::fromCallable($this->translateDateTime(...)),
            '@' => Closure::fromCallable($this->translatePoint(...)),
            '#' => Closure::fromCallable($this->translateBinary(...)),
            '[]' => Closure::fromCallable($this->translateList(...)),
            '{}' => Closure::fromCallable($this->translateMap(...)),
            '()' => Closure::fromCallable($this->translateNode(...)),
            '->' => Closure::fromCallable($this->translateRightRelationship(...)),
            '<-' => Closure::fromCallable($this->translateLeftRelationship(...)),
            '..' => Closure::fromCallable($this->translatePath(...)),
        ];
    }

    public function decorateRequest(RequestInterface $request): RequestInterface
    {
        /** @psalm-suppress ImpureMethodCall */
        return $request->withHeader(
            'Accept',
            'application/vnd.neo4j.jolt+json-seq;strict=true;charset=UTF-8'
        );
    }

    /**
     * @return array{resultDataContents?: list<'GRAPH'|'ROW'|'REST'>, includeStats?:bool}
     */
    public function statementConfigOverride(): array
    {
        return [];
    }

    /**
     * @return CypherList<CypherList<CypherMap<OGMTypes>>>
     */
    public function formatHttpResult(
        ResponseInterface $response,
        stdClass $body,
        ConnectionInterface $connection,
        float $resultsAvailableAfter,
        float $resultsConsumedAfter,
        iterable $statements
    ): CypherList {
        $allResults = [];
        /** @var stdClass $result */
        foreach ($body->results as $result) {
            /** @var stdClass $header */
            $header = $result->header;
            /** @var list<string> $fields */
            $fields = $header->fields;
            $rows = [];

            /** @var list<stdClass> $data */
            foreach ($result->data as $data) {
                $row = [];
                foreach ($data as $key => $value) {
                    $row[$fields[$key]] = $this->translateJoltType($value);
                }
                $rows[] = new CypherMap($row);
            }
            $allResults[] = new CypherList($rows);
        }

        return new CypherList($allResults);
    }

    /**
     * @return OGMTypes
     */
    private function translateJoltType(?stdClass $value)
    {
        if (is_null($value)) {
            return null;
        }

        /** @var mixed $input */
        [$key, $input] = HttpHelper::splitJoltSingleton($value);
        if (!array_key_exists($key, $this->rawToTypes)) {
            throw new UnexpectedValueException('Unexpected Jolt key: '.$key);
        }

        return $this->rawToTypes[$key]($input);
    }

    /**
     * Assumes that 2D points are of the form "SRID=$srid;POINT($x $y)" and 3D points are of the form "SRID=$srid;POINT Z($x $y $z)".
     *
     * @throws UnexpectedValueException
     */
    private function translatePoint(string $value): PointInterface
    {
        [$srid, $coordinates] = explode(';', $value, 2);

        $srid = $this->getSRID($srid);
        $coordinates = $this->getCoordinates($coordinates);

        if ($srid === CartesianPoint::SRID) {
            return new CartesianPoint(
                (float) $coordinates[0],
                (float) $coordinates[1],
            );
        }
        if ($srid === Cartesian3DPoint::SRID) {
            return new Cartesian3DPoint(
                (float) $coordinates[0],
                (float) $coordinates[1],
                (float) ($coordinates[2] ?? 0.0),
            );
        }
        if ($srid === WGS84Point::SRID) {
            return new WGS84Point(
                (float) $coordinates[0],
                (float) $coordinates[1],
            );
        }
        if ($srid === WGS843DPoint::SRID) {
            return new WGS843DPoint(
                (float) $coordinates[0],
                (float) $coordinates[1],
                (float) ($coordinates[2] ?? 0.0),
            );
        }
        throw new UnexpectedValueException('A point with srid '.$srid.' has been returned, which has not been implemented.');
    }

    private function getSRID(string $value): int
    {
        $matches = [];
        if (!preg_match('/^SRID=([0-9]+)$/', $value, $matches)) {
            throw new UnexpectedValueException('Unexpected SRID string: '.$value);
        }

        /** @var array{0: string, 1: string} $matches */
        return (int) $matches[1];
    }

    /**
     * @return array{0: string, 1: string, 2?: string} $coordinates
     */
    private function getCoordinates(string $value): array
    {
        $matches = [];
        if (!preg_match('/^POINT ?(Z?) ?\(([0-9. ]+)\)$/', $value, $matches)) {
            throw new UnexpectedValueException('Unexpected point coordinates string: '.$value);
        }
        /** @var array{0: string, 1: string, 2: string} $matches */
        $coordinates = explode(' ', $matches[2]);
        if ($matches[1] === 'Z' && count($coordinates) !== 3) {
            throw new UnexpectedValueException('Expected 3 coordinates in string: '.$value);
        }

        if ($matches[1] !== 'Z' && count($coordinates) !== 2) {
            throw new UnexpectedValueException('Expected 2 coordinates in string: '.$value);
        }

        /** @var array{0: string, 1: string, 2?: string} */
        return $coordinates;
    }

    /**
     * @return CypherMap<OGMTypes>
     */
    private function translateMap(stdClass $value): CypherMap
    {
        return new CypherMap(
            function () use ($value) {
                /** @var stdClass|array|null $element */
                foreach ((array) $value as $key => $element) {
                    // There is an odd case in the JOLT protocol when dealing with properties in a node.
                    // Lists appear not to receive a composite type label,
                    // which is why we have to handle them specifically here.
                    // @see https://github.com/neo4j/neo4j/issues/12858
                    if (is_array($element)) {
                        yield $key => new CypherList($element);
                    } else {
                        yield $key => $this->translateJoltType($element);
                    }
                }
            }
        );
    }

    private function translateList(array $value): CypherList
    {
        return new CypherList(
            function () use ($value) {
                /** @var stdClass|null $element */
                foreach ($value as $element) {
                    yield $this->translateJoltType($element);
                }
            }
        );
    }

    /**
     * @param list<stdClass> $value
     */
    private function translatePath(array $value): Path
    {
        $nodes = [];
        /** @var list<UnboundRelationship> $relations */
        $relations = [];
        $ids = [];
        foreach ($value as $nodeOrRelation) {
            /** @var Node|Relationship $nodeOrRelation */
            $nodeOrRelation = $this->translateJoltType($nodeOrRelation);

            if ($nodeOrRelation instanceof Relationship) {
                $relations[] = $nodeOrRelation;
            } else {
                $nodes[] = $nodeOrRelation;
            }

            $ids[] = $nodeOrRelation->getId();
        }

        return new Path(new CypherList($nodes), new CypherList($relations), new CypherList($ids));
    }

    /**
     * @param array{0: int, 1: list<string>, 2: stdClass} $value
     */
    private function translateNode(array $value): Node
    {
        return new Node($value[0], new CypherList($value[1]), $this->translateMap($value[2]), null);
    }

    /**
     * @param array{0:int, 1: int, 2: string, 3:int, 4: stdClass} $value
     */
    private function translateRightRelationship(array $value): Relationship
    {
        return new Relationship($value[0], $value[1], $value[3], $value[2], $this->translateMap($value[4]), null);
    }

    /**
     * @param array{0:int, 1: int, 2: string, 3:int, 4: stdClass} $value
     */
    private function translateLeftRelationship(array $value): Relationship
    {
        return new Relationship($value[0], $value[3], $value[1], $value[2], $this->translateMap($value[4]), null);
    }

    private function translateBinary(): Closure
    {
        throw new UnexpectedValueException('Binary data has not been implemented');
    }

    private const TIME_REGEX = '(?<hours>\d{2}):(?<minutes>\d{2}):(?<seconds>\d{2})((\.)(?<nanoseconds>\d+))?';
    private const DATE_REGEX = '(?<date>[\-−]?\d+-\d{2}-\d{2})';
    private const ZONE_REGEX = '(?<zone>.+)';

    /**
     * @psalm-suppress ImpureMethodCall
     * @psalm-suppress ImpureFunctionCall
     * @psalm-suppress PossiblyFalseReference
     */
    private function translateDateTime(string $datetime): Date|LocalDateTime|LocalTime|DateTime|Duration|Time
    {
        if (preg_match('/^'.self::DATE_REGEX.'$/u', $datetime, $matches)) {
            $days = $this->daysFromMatches($matches);

            return new Date($days);
        }

        if (preg_match('/^'.self::TIME_REGEX.'$/u', $datetime, $matches)) {
            $nanoseconds = $this->nanosecondsFromMatches($matches);

            return new LocalTime($nanoseconds);
        }

        if (preg_match('/^'.self::TIME_REGEX.self::ZONE_REGEX.'$/u', $datetime, $matches)) {
            $nanoseconds = $this->nanosecondsFromMatches($matches);

            $offset = $this->offsetFromMatches($matches);

            return new Time($nanoseconds, $offset);
        }

        if (preg_match('/^'.self::DATE_REGEX.'T'.self::TIME_REGEX.'$/u', $datetime, $matches)) {
            $nanoseconds = $this->nanosecondsFromMatches($matches);
            $seconds = $this->secondsInDaysFromMatches($matches);

            [$seconds, $nanoseconds] = $this->addNanoSecondsToSeconds($nanoseconds, $seconds);

            return new LocalDateTime($seconds, $nanoseconds);
        }

        if (preg_match('/^'.self::DATE_REGEX.'T'.self::TIME_REGEX.self::ZONE_REGEX.'$/u', $datetime, $matches)) {
            $nanoseconds = $this->nanosecondsFromMatches($matches);
            $seconds = $this->secondsInDaysFromMatches($matches);

            [$seconds, $nanoseconds] = $this->addNanoSecondsToSeconds($nanoseconds, $seconds);

            $offset = $this->offsetFromMatches($matches);

            return new DateTime($seconds, $nanoseconds, $offset, true);
        }

        if (str_starts_with($datetime, 'P')) {
            return $this->durationFromFormat($datetime);
        }

        throw new UnexpectedValueException(sprintf('Could not handle date/time "%s"', $datetime));
    }

    private function nanosecondsFromMatches(array $matches): int
    {
        /** @var array{0: string, hours: string, minutes: string, seconds: string, nanoseconds?:  string} $matches */
        ['hours' => $hours, 'minutes' => $minutes, 'seconds' => $seconds] = $matches;
        $seconds = (((int) $hours) * 60 * 60) + (((int) $minutes) * 60) + ((int) $seconds);

        $nanoseconds = $matches['nanoseconds'] ?? '0';
        $nanoseconds = str_pad($nanoseconds, 9, '0', STR_PAD_RIGHT);

        return $seconds * 1000 * 1000 * 1000 + (int) $nanoseconds;
    }

    private function offsetFromMatches(array $matches): int
    {
        /** @var array{zone: string} $matches */
        $zone = $matches['zone'];

        if (preg_match('/(\d{2}):(\d{2})/', $zone, $matches)) {
            /** @var array{0: string, 1: string, 2: string} $matches */
            return ((int) $matches[1]) * 60 * 60 + (int) $matches[2] * 60;
        }

        return 0;
    }

    private function daysFromMatches(array $matches): int
    {
        /** @var array{date: string} $matches */
        $date = DateTimeImmutable::createFromFormat('Y-m-d', $matches['date']);
        if ($date === false) {
            throw new RuntimeException(sprintf('Cannot create DateTime from "%s" in format "Y-m-d"', $matches['date']));
        }

        /** @psalm-suppress ImpureMethodCall */
        return (int) $date->diff(new DateTimeImmutable('@0'))->format('%a');
    }

    private function secondsInDaysFromMatches(array $matches): int
    {
        /** @var array{date: string} $matches */
        $date = DateTimeImmutable::createFromFormat(DATE_ATOM, $matches['date'].'T00:00:00+00:00');
        if ($date === false) {
            throw new RuntimeException(sprintf('Cannot create DateTime from "%s" in format "Y-m-d"', $matches['date']));
        }

        return $date->getTimestamp();
    }

    /**
     * @return array{0: int, 1: int}
     */
    private function addNanoSecondsToSeconds(int $nanoseconds, int $seconds): array
    {
        $seconds += (int) ($nanoseconds / 1000 / 1000 / 1000);
        $nanoseconds %= 1_000_000_000;

        return [$seconds, $nanoseconds];
    }

    /**
     * @psalm-suppress ImpureMethodCall
     */
    private function durationFromFormat(string $datetime): Duration
    {
        $nanoseconds = 0;
        // PHP date interval does not understand fractions of a second.
        if (preg_match('/\.(?<nanoseconds>\d+)S/u', $datetime, $matches)) {
            /** @var array{0: string, nanoseconds: string} $matches */
            $nanoseconds = (int) str_pad($matches['nanoseconds'], 9, '0', STR_PAD_RIGHT);

            $datetime = str_replace($matches[0], 'S', $datetime);
        }

        $interval = new DateInterval($datetime);
        $months = (int) $interval->format('%y') * 12 + (int) $interval->format('%m');
        $days = (int) $interval->format('%d');
        $seconds = (int) $interval->format('%h') * 60 * 60 + (int) $interval->format('%i') * 60 + (int) $interval->format('%s');

        return new Duration($months, $days, $seconds, $nanoseconds);
    }
}