laudis-technologies/neo4j-php-client

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

Summary

Maintainability
C
1 day
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 function array_combine;
use function array_key_exists;
use function count;
use function date;

use DateInterval;
use DateTimeImmutable;
use Exception;

use function explode;
use function is_array;
use function is_object;
use function is_string;
use function json_encode;

use const JSON_THROW_ON_ERROR;

use Laudis\Neo4j\Contracts\ConnectionInterface;
use Laudis\Neo4j\Contracts\PointInterface;
use Laudis\Neo4j\Formatter\OGMFormatter;
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 Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use RuntimeException;

use function sprintf;

use stdClass;

use function str_pad;
use function substr;

use UnexpectedValueException;

/**
 * @psalm-import-type OGMTypes from OGMFormatter
 *
 * @psalm-immutable
 */
final class LegacyHttpOGMTranslator
{
    /**
     * @psalm-mutation-free
     *
     * @return CypherList<CypherList<CypherMap<OGMTypes>>>
     */
    public function formatHttpResult(
        ResponseInterface $response,
        stdClass $body,
        ConnectionInterface $connection,
        float $resultsAvailableAfter,
        float $resultsConsumedAfter,
        iterable $statements
    ): CypherList {
        /** @var list<CypherList<CypherMap<OGMTypes>>> $tbr */
        $tbr = [];

        /** @var list<stdClass> $results */
        $results = $body->results;
        foreach ($results as $result) {
            $tbr[] = $this->translateResult($result);
        }

        return new CypherList($tbr);
    }

    public function decorateRequest(RequestInterface $request): RequestInterface
    {
        return $request;
    }

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

    /**
     * @throws Exception
     *
     * @return CypherList<CypherMap<OGMTypes>>
     */
    public function translateResult(stdClass $result): CypherList
    {
        /** @var list<CypherMap<OGMTypes>> $tbr */
        $tbr = [];

        /** @var list<string> $columns */
        $columns = $result->columns;
        /** @var list<stdClass> $datas */
        $datas = $result->data;
        foreach ($datas as $data) {
            $meta = HttpMetaInfo::createFromData($data);

            /** @var list<stdClass> $row */
            $row = $data->row;
            $row = array_combine($columns, $row);
            $tbr[] = $this->translateCypherMap($row, $meta)[0];
        }

        return new CypherList($tbr);
    }

    /**
     * @param array<string, stdClass> $row
     *
     * @return array{0: CypherMap<OGMTypes>, 1: HttpMetaInfo}
     */
    public function translateCypherMap(array $row, HttpMetaInfo $meta): array
    {
        /** @var array<string, OGMTypes> $record */
        $record = [];
        foreach ($row as $key => $value) {
            [$translation, $meta] = $this->translateValue($value, $meta);

            $record[$key] = $translation;
        }

        return [new CypherMap($record), $meta];
    }

    /**
     * @param stdClass|array|scalar|null $value
     *
     * @return array{0: OGMTypes, 1: HttpMetaInfo}
     *
     * @psalm-suppress MixedArgumentTypeCoercion
     * @psalm-suppress MixedArgument
     * @psalm-suppress MixedAssignment
     */
    private function translateValue($value, HttpMetaInfo $meta): array
    {
        if (is_object($value)) {
            return $this->translateObject($value, $meta);
        }

        if (is_array($value)) {
            if ($meta->getCurrentType() === 'path') {
                /**
                 * There are edge cases where multiple paths are wrapped in a list.
                 *
                 * @see OGMFormatterIntegrationTest::testPathMultiple for an example
                 */
                if (array_key_exists(0, $value) && is_array($value[0])) {
                    $tbr = [];
                    foreach ($value as $path) {
                        $tbr[] = $this->path($path, $meta->withNestedMeta());
                        $meta = $meta->incrementMeta();
                    }

                    return [new CypherList($tbr), $meta];
                }

                $tbr = $this->path($value, $meta->withNestedMeta());
                $meta = $meta->incrementMeta();

                return [$tbr, $meta];
            }

            return $this->translateCypherList($value, $meta);
        }

        if (is_string($value)) {
            return $this->translateString($value, $meta);
        }

        return [$value, $meta->incrementMeta()];
    }

    /**
     * @return array{0: Cartesian3DPoint|CartesianPoint|CypherList|CypherMap|Node|Relationship|WGS843DPoint|WGS84Point|Path, 1: HttpMetaInfo}
     *
     * @psalm-suppress MixedArgument
     * @psalm-suppress MixedArgumentTypeCoercion
     */
    private function translateObject(stdClass $value, HttpMetaInfo $meta): array
    {
        $type = $meta->getCurrentType();
        if ($type === 'relationship') {
            /** @var stdClass $relationship */
            $relationship = $meta->getCurrentRelationship();

            return $this->relationship($relationship, $meta);
        }

        if ($type === 'point') {
            return [$this->translatePoint($value), $meta];
        }

        if ($type === 'node') {
            $node = $meta->currentNode();
            if ($node && json_encode($value, JSON_THROW_ON_ERROR) === json_encode($node->properties, JSON_THROW_ON_ERROR)) {
                $meta = $meta->incrementMeta();
                $map = $this->translateProperties((array) $node->properties);

                return [new Node((int) $node->id, new CypherList($node->labels), $map, null), $meta];
            }
        }

        return $this->translateCypherMap((array) $value, $meta);
    }

    /**
     * @param array<string, array|stdClass|scalar|null> $properties
     *
     * @return CypherMap<OGMTypes>
     */
    private function translateProperties(array $properties): CypherMap
    {
        $tbr = [];
        foreach ($properties as $key => $value) {
            if ($value instanceof stdClass) {
                /** @var array<string, array|stdClass|scalar|null> $castedValue */
                $castedValue = (array) $value;
                $tbr[$key] = $this->translateProperties($castedValue);
            } elseif (is_array($value)) {
                /** @var array<string, array|stdClass|scalar|null> $value */
                $tbr[$key] = new CypherList($this->translateProperties($value));
            } else {
                $tbr[$key] = $value;
            }
        }
        /** @var CypherMap<OGMTypes> */
        return new CypherMap($tbr);
    }

    /**
     * @psalm-suppress MixedArgument
     * @psalm-suppress MixedArgumentTypeCoercion
     *
     * @return array{0: Relationship, 1: HttpMetaInfo}
     */
    private function relationship(stdClass $relationship, HttpMetaInfo $meta): array
    {
        $meta = $meta->incrementMeta();
        $map = $this->translateProperties((array) $relationship->properties);

        $tbr = new Relationship(
            (int) $relationship->id,
            (int) $relationship->startNode,
            (int) $relationship->endNode,
            $relationship->type,
            $map,
            null
        );

        return [$tbr, $meta];
    }

    /**
     * @param list<scalar|array|null> $value
     *
     * @return array{0: CypherList<OGMTypes>, 1: HttpMetaInfo}
     */
    private function translateCypherList(array $value, HttpMetaInfo $meta): array
    {
        /** @var array<OGMTypes> $tbr */
        $tbr = [];
        foreach ($value as $x) {
            [$x, $meta] = $this->translateValue($x, $meta);
            $tbr[] = $x;
        }

        return [new CypherList($tbr), $meta];
    }

    /**
     * @param list<stdClass> $value
     */
    private function path(array $value, HttpMetaInfo $meta): Path
    {
        /** @var list<Node> $nodes */
        $nodes = [];
        /** @var list<int> $ids */
        $ids = [];
        /** @var list<UnboundRelationship> $rels */
        $rels = [];

        foreach ($value as $x) {
            /** @var stdClass $currentMeta */
            $currentMeta = $meta->currentMeta();
            /** @var int $id */
            $id = $currentMeta->id;
            $ids[] = $id;
            [$x, $meta] = $this->translateObject($x, $meta);
            if ($x instanceof Node) {
                $nodes[] = $x;
            } elseif ($x instanceof Relationship) {
                $rels[] = new UnboundRelationship($x->getId(), $x->getType(), $x->getProperties(), null);
            }
        }

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

    /**
     * @return CartesianPoint|Cartesian3DPoint|WGS843DPoint|WGS84Point
     */
    private function translatePoint(stdClass $value): PointInterface
    {
        /** @var stdClass $crs */
        $crs = $value->crs;
        /** @var array{0: float, 1: float, 2:float} $coordinates */
        $coordinates = $value->coordinates;
        /** @var int $srid */
        $srid = $crs->srid;
        if ($srid === CartesianPoint::SRID) {
            return new CartesianPoint(
                $coordinates[0],
                $coordinates[1],
            );
        }
        if ($srid === Cartesian3DPoint::SRID) {
            return new Cartesian3DPoint(
                $coordinates[0],
                $coordinates[1],
                $coordinates[2],
            );
        }
        if ($srid === WGS84Point::SRID) {
            return new WGS84Point(
                $coordinates[0],
                $coordinates[1],
            );
        }
        if ($srid === WGS843DPoint::SRID) {
            return new WGS843DPoint(
                $coordinates[0],
                $coordinates[1],
                $coordinates[2],
            );
        }
        /** @var string $name */
        $name = $crs->name;
        throw new UnexpectedValueException('A point with srid '.$srid.' and name '.$name.' has been returned, which has not been implemented.');
    }

    /**
     * @throws Exception
     *
     * @return array{0: string|Date|DateTime|Duration|LocalDateTime|LocalTime|Time, 1: HttpMetaInfo}
     */
    public function translateString(string $value, HttpMetaInfo $meta): array
    {
        switch ($meta->getCurrentType()) {
            case 'duration':
                $meta = $meta->incrementMeta();
                $tbr = [$this->translateDuration($value), $meta];
                break;
            case 'datetime':
                $meta = $meta->incrementMeta();
                $tbr = [$this->translateDateTime($value), $meta];
                break;
            case 'date':
                $meta = $meta->incrementMeta();
                $tbr = [$this->translateDate($value), $meta];
                break;
            case 'time':
                $meta = $meta->incrementMeta();
                $tbr = [$this->translateTime($value), $meta];
                break;
            case 'localdatetime':
                $meta = $meta->incrementMeta();
                $tbr = [$this->translateLocalDateTime($value), $meta];
                break;
            case 'localtime':
                $meta = $meta->incrementMeta();
                $tbr = [$this->translateLocalTime($value), $meta];
                break;
            default:
                $tbr = [$value, $meta->incrementMeta()];
                break;
        }

        return $tbr;
    }

    /**
     * @throws Exception
     */
    private function translateDuration(string $value): Duration
    {
        /** @psalm-suppress ImpureFunctionCall false positive in version php 7.4 */
        if (str_contains($value, '.')) {
            [$format, $secondsFraction] = explode('.', $value);
            $nanoseconds = (int) substr($secondsFraction, 6);
            $microseconds = (int) str_pad((string) ((int) substr($secondsFraction, 0, 6)), 6, '0');
            $interval = new DateInterval($format.'S');
            $x = new DateTimeImmutable();
            /** @psalm-suppress PossiblyFalseReference */
            $interval = $x->add($interval)->modify('+'.$microseconds.' microseconds')->diff($x);
        } else {
            $nanoseconds = 0;
            $interval = new DateInterval($value);
        }

        $months = $interval->y * 12 + $interval->m;
        $days = $interval->d;
        $seconds = $interval->h * 60 * 60 + $interval->i * 60 + $interval->s;
        $nanoseconds = (int) ($interval->f * 1_000_000_000) + $nanoseconds;

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

    private function translateDate(string $value): Date
    {
        $epoch = new DateTimeImmutable('@0');
        $dateTime = DateTimeImmutable::createFromFormat('Y-m-d', $value);
        if ($dateTime === false) {
            throw new RuntimeException(sprintf('Could not create date from format "Y-m-d" and %s', $value));
        }

        $diff = $dateTime->diff($epoch);

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

    private function translateTime(string $value): Time
    {
        $value = substr($value, 0, 5);
        $values = explode(':', $value);

        /** @psalm-suppress PossiblyUndefinedIntArrayOffset */
        return new Time((((int) $values[0]) * 60 * 60 + ((int) $values[1]) * 60) * 1_000_000_000, 0);
    }

    /**
     * @throws Exception
     */
    private function translateDateTime(string $value): DateTime
    {
        [$date, $time] = explode('T', $value);
        $tz = null;
        /** @psalm-suppress ImpureFunctionCall false positive in version php 7.4 */
        if (str_contains($time, '+')) {
            [$time, $timezone] = explode('+', $time);
            [$tzHours, $tzMinutes] = explode(':', $timezone);
            $tz = (int) $tzHours * 60 * 60 + (int) $tzMinutes * 60;
        }
        [$time, $milliseconds] = explode('.', $time);

        $dateTime = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $date.' '.$time);
        if ($dateTime === false) {
            throw new RuntimeException(sprintf('Could not create date from format "Y-m-d H:i:s" and %s', $date.' '.$time));
        }

        if ($tz !== null) {
            return new DateTime($dateTime->getTimestamp(), (int) $milliseconds * 1_000_000, $tz, true);
        }

        return new DateTime($dateTime->getTimestamp(), (int) $milliseconds * 1_000_000, 0, true);
    }

    private function translateLocalDateTime(string $value): LocalDateTime
    {
        [$date, $time] = explode('T', $value);
        [$time, $milliseconds] = explode('.', $time);

        $dateTime = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $date.' '.$time);
        if ($dateTime === false) {
            throw new RuntimeException(sprintf('Could not create date from format "Y-m-d H:i:s" and %s', $date.' '.$time));
        }

        return new LocalDateTime($dateTime->getTimestamp(), (int) $milliseconds * 1_000_000);
    }

    /**
     * @psalm-suppress all
     *
     * @throws Exception
     */
    private function translateLocalTime(string $value): LocalTime
    {
        $timestamp = (new DateTimeImmutable($value))->getTimestamp();

        $hours = (int) date('H', $timestamp);
        $minutes = (int) date('i', $timestamp);
        $seconds = (int) date('s', $timestamp);
        $milliseconds = 0;

        $values = explode('.', $value);
        if (count($values) > 1) {
            $milliseconds = $values[1];
        }

        $totalSeconds = ($hours * 3600) + ($minutes * 60) + $seconds + ($milliseconds / 1000);

        return new LocalTime((int) $totalSeconds * 1_000_000_000);
    }
}