Skip to content

Instantly share code, notes, and snippets.

@crisu83
Last active April 18, 2018 21:09
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save crisu83/71584fdf72f1206211a93b7fdf3336a3 to your computer and use it in GitHub Desktop.
Save crisu83/71584fdf72f1206211a93b7fdf3336a3 to your computer and use it in GitHub Desktop.
WIP Relay concept for our GraphQL implementation
<?php
namespace ALehdet\AspaApi\GraphQL\Relay;
use Digia\GraphQL\Relay\PageInfo;
use Digia\GraphQL\Relay\PageInfoBuilderInterface;
abstract class AbstractPageBuilder implements PageInfoBuilderInterface
{
/**
* @inheritdoc
*/
public function build(): PageInfo
{
$startCursor = $this->getStartCursor();
$endCursor = $this->getEndCursor();
$hasPreviousPage = $this->hasPreviousPage();
$hasNextPage = $this->hasNextPage();
return new PageInfo($startCursor, $endCursor, $hasPreviousPage, $hasNextPage);
}
/**
* @return null|string
*/
public function getStartCursor(): ?string
{
$firstEdge = $this->getFirstEdge();
return null !== $firstEdge ? $firstEdge->getCursor() : null;
}
/**
* @return null|string
*/
public function getEndCursor(): ?string
{
$lastEdge = $this->getLastEdge();
return null !== $lastEdge ? $lastEdge->getCursor() : null;
}
/**
* @return bool
*/
public function hasPreviousPage(): bool
{
return false; // not supported
}
/**
* @return bool
*/
public function hasNextPage(): bool
{
return false; // not supported
}
}
<?php
namespace Digia\GraphQL\Relay;
class ArrayConnection implements ConnectionInterface
{
/**
* @var array
*/
protected $data;
/**
* @var ConnectionArguments
*/
protected $arguments;
/**
* @var Edge[]
*/
protected $edgeMap;
/**
* @var PageInfo
*/
protected $pageInfo;
/**
* Connection constructor.
*
* @param array $data
* @param PageInfo $pageInfo
* @param ConnectionArguments $arguments
*/
public function __construct(array $data, ConnectionArguments $arguments)
{
$this->data = $data;
$this->arguments = $arguments;
$this->edgeMap = $this->createEdgeMap();
$this->pageInfo = $this->createPageInfo();
}
/**
* @inheritdoc
*/
public function getEdges(): iterable
{
$cursors = \array_keys($this->edgeMap);
$edges = \array_values($this->edgeMap);
$startOffset = \array_search($this->pageInfo->getStartCursor(), $cursors, true);
$endOffset = \array_search($this->pageInfo->getEndCursor(), $cursors, true);
$offset = false !== $startOffset
? $startOffset
: 0;
$length = false !== $endOffset
? $endOffset - $startOffset + 1
: null;
return \array_slice($edges, $offset, $length, true);
}
/**
* @inheritdoc
*/
public function getPageInfo(): PageInfo
{
return $this->pageInfo;
}
/**
* @return Edge[]
*/
protected function createEdgeMap(): array
{
return \array_reduce($this->data, function ($data, NodeInterface $node) {
$cursor = $this->encodeCursor($node->createCursor($this->arguments));
$edge = new Edge($cursor, $node);
$data[$cursor] = $edge;
return $data;
}, []);
}
/**
* @return PageInfo
*/
protected function createPageInfo(): PageInfo
{
return (new ArrayPageInfoBuilder($this->edgeMap, $this->arguments))->build();
}
/**
* @param string $cursor
* @return string
*/
protected function encodeCursor(string $cursor): string
{
return base64_encode($cursor);
}
}
<?php
namespace Digia\GraphQL\Relay;
use ALehdet\AspaApi\GraphQL\Relay\AbstractPageBuilder;
class ArrayPageInfoBuilder extends AbstractPageBuilder
{
/**
* A list of cursors for edges in the connection.
*
* @var string[]
*/
protected $cursors;
/**
* A list of edges in the connection.
*
* @var Edge[]
*/
protected $edges;
/**
* The arguments given to the connection.
*
* @var ConnectionArguments
*/
protected $arguments;
/**
* The number of selected items determined by the `first` or `last` argument.
*
* @var int|null
*/
protected $selection;
/**
* The total number of edges in the connection.
*
* @var int
*/
protected $total;
/**
* ArrayPageInfoBuilder constructor.
*
* @param array $edgeMap
* @param ConnectionArguments $arguments
* @param int|null $total
*/
public function __construct(array $edgeMap, ConnectionArguments $arguments, ?int $total = null)
{
$this->cursors = \array_keys($edgeMap);
$this->edges = \array_values($edgeMap);
$this->arguments = $arguments;
$this->selection = $arguments->getFirst() ?? $arguments->getLast();
$this->total = $total ?? \count($edgeMap);
}
/**
* @return bool
* @throws PaginationException
*/
public function hasPreviousPage(): bool
{
return $this->getFirstIndex() > 0;
}
/**
* @return bool
* @throws PaginationException
*/
public function hasNextPage(): bool
{
return $this->getLastIndex() < ($this->total - 1);
}
/**
* @return Edge|null
* @throws PaginationException
*/
public function getFirstEdge(): ?Edge
{
return $this->edges[$this->getFirstIndex()] ?? null;
}
/**
* @return Edge|null
* @throws PaginationException
*/
public function getLastEdge(): ?Edge
{
return $this->edges[$this->getLastIndex()] ?? null;
}
/**
* @return int
* @throws PaginationException
*/
protected function getFirstIndex(): int
{
$selectionCount = $this->selection ?? $this->total;
$afterIndex = $this->getAfterIndex();
if ($afterIndex >= 0) {
$index = $afterIndex + 1;
$this->assertUpperBounds($index);
return $index;
}
$beforeIndex = $this->getBeforeIndex();
if ($beforeIndex >= 0) {
$index = $beforeIndex - $selectionCount;
$this->assertLowerBounds($index);
return $index;
}
return 0;
}
/**
* @return int
* @throws PaginationException
*/
protected function getLastIndex(): int
{
$selectionCount = $this->selection ?? $this->total;
$afterIndex = $this->getAfterIndex();
if ($afterIndex >= 0) {
$index = $afterIndex + $selectionCount;
$this->assertUpperBounds($index);
return $index;
}
$beforeIndex = $this->getBeforeIndex();
if ($beforeIndex >= 0) {
$index = $beforeIndex - 1;
$this->assertLowerBounds($index);
return $index;
}
$index = $selectionCount - 1;
return $index;
}
/**
* @return int
* @throws PaginationException
*/
protected function getBeforeIndex(): int
{
$before = $this->arguments->getBefore();
if (null === $before) {
return -1;
}
if (false === ($index = \array_search($before, $this->cursors, true))) {
throw new PaginationException(\sprintf('Invalid cursor: "%s"', $before));
}
return $index;
}
/**
* @return int
* @throws PaginationException
*/
protected function getAfterIndex(): int
{
$after = $this->arguments->getAfter();
if (null === $after) {
return -1;
}
if (false === ($index = \array_search($after, $this->cursors, true))) {
throw new PaginationException(\sprintf('Invalid cursor: "%s"', $after));
}
return $index;
}
/**
* @param int $index
* @throws PaginationException
*/
protected function assertLowerBounds(int $index): void
{
if ($index < 0) {
throw new PaginationException(\sprintf('Index out of bounds: %d', $index));
}
}
/**
* @param int $index
* @throws PaginationException
*/
protected function assertUpperBounds(int $index): void
{
if ($index > $this->total) {
throw new PaginationException(\sprintf('Index out of bounds: %d', $index));
}
}
}
<?php
namespace Digia\GraphQL\Test\Relay;
use Digia\GraphQL\Relay\ArrayPageInfoBuilder;
use Digia\GraphQL\Relay\ConnectionArguments;
use Digia\GraphQL\Relay\Edge;
use Digia\GraphQL\Relay\NodeInterface;
use Digia\GraphQL\Relay\PageInfo;
use Digia\GraphQL\Relay\PaginationException;
use PHPUnit\Framework\TestCase;
class ArrayPageInfoBuilderTest extends TestCase
{
/**
* @var array
*/
protected $productData;
public function setUp()
{
$this->productData = [
[
'id' => '8hUrNOc',
'title' => 'Demi',
'description' => 'The Demi magazine.',
'createdAt' => '2018-04-18 18:54:32',
],
[
'id' => '-QHEw3V',
'title' => 'Apu',
'description' => 'The Apu magazine.',
'createdAt' => '2018-04-18 18:54:37',
],
[
'id' => 'BjOLIWM',
'title' => 'Kauneus ja Terveys',
'description' => 'The Kauneus ja Terveys magazine.',
'createdAt' => '2018-04-18 18:54:13',
],
[
'id' => 'uEPthWb',
'title' => 'Meidän Talo',
'description' => 'The Meidän Talo magazine.',
'createdAt' => '2018-04-18 18:54:43',
],
[
'id' => 'A9-6eZl',
'title' => 'Image',
'description' => 'The Image magazine.',
'createdAt' => '2018-04-18 18:54:48',
],
[
'id' => 'v1e3oQq',
'title' => 'Eeva',
'description' => 'The Eeva magazine.',
'createdAt' => '2018-04-18 18:54:53',
]
];
}
public function testWithoutArguments()
{
$arguments = $this->createConnectionArguments();
$edgeMap = $this->createEdgeMap($this->productData, $arguments);
$pageInfo = $this->createPageInfo($edgeMap, $arguments);
$this->assertEquals('MjAxOC0wNC0xOCAxODo1NDozMg==', $pageInfo->getStartCursor());
$this->assertEquals('MjAxOC0wNC0xOCAxODo1NDo1Mw==', $pageInfo->getEndCursor());
$this->assertFalse($pageInfo->hasPreviousPage());
$this->assertFalse($pageInfo->hasNextPage());
}
public function testFirstAndAfter()
{
$arguments = $this->createConnectionArguments([
'first' => 3,
'after' => 'MjAxOC0wNC0xOCAxODo1NDoxMw=='
]);
$edgeMap = $this->createEdgeMap($this->productData, $arguments);
$pageInfo = $this->createPageInfo($edgeMap, $arguments);
$this->assertEquals('MjAxOC0wNC0xOCAxODo1NDo0Mw==', $pageInfo->getStartCursor());
$this->assertEquals('MjAxOC0wNC0xOCAxODo1NDo1Mw==', $pageInfo->getEndCursor());
$this->assertTrue($pageInfo->hasPreviousPage());
$this->assertFalse($pageInfo->hasNextPage());
}
public function testInvalidFirst()
{
$arguments = $this->createConnectionArguments([
'first' => 100,
'after' => 'MjAxOC0wNC0xOCAxODo1NDoxMw=='
]);
$edgeMap = $this->createEdgeMap($this->productData, $arguments);
$this->expectException(PaginationException::class);
$this->expectExceptionMessage('Index out of bounds: 102');
$this->createPageInfo($edgeMap, $arguments);
}
public function testInvalidAfter()
{
$arguments = $this->createConnectionArguments([
'first' => 3,
'after' => 'SomeInvalidCursor'
]);
$edgeMap = $this->createEdgeMap($this->productData, $arguments);
$this->expectException(PaginationException::class);
$this->expectExceptionMessage('Invalid cursor: "SomeInvalidCursor"');
$this->createPageInfo($edgeMap, $arguments);
}
public function testLastAndBefore()
{
$arguments = $this->createConnectionArguments([
'last' => 2,
'before' => 'MjAxOC0wNC0xOCAxODo1NDo1Mw=='
]);
$edgeMap = $this->createEdgeMap($this->productData, $arguments);
$pageInfo = $this->createPageInfo($edgeMap, $arguments);
$this->assertEquals('MjAxOC0wNC0xOCAxODo1NDo0Mw==', $pageInfo->getStartCursor());
$this->assertEquals('MjAxOC0wNC0xOCAxODo1NDo0OA==', $pageInfo->getEndCursor());
$this->assertTrue($pageInfo->hasPreviousPage());
$this->assertTrue($pageInfo->hasNextPage());
}
public function testInvalidLast()
{
$arguments = $this->createConnectionArguments([
'last' => 50,
'before' => 'MjAxOC0wNC0xOCAxODo1NDo1Mw=='
]);
$edgeMap = $this->createEdgeMap($this->productData, $arguments);
$this->expectException(PaginationException::class);
$this->expectExceptionMessage('Index out of bounds: -45');
$this->createPageInfo($edgeMap, $arguments);
}
public function testInvalidBefore()
{
$arguments = $this->createConnectionArguments([
'last' => 2,
'before' => 'SomeInvalidCursor'
]);
$edgeMap = $this->createEdgeMap($this->productData, $arguments);
$this->expectException(PaginationException::class);
$this->expectExceptionMessage('Invalid cursor: "SomeInvalidCursor"');
$this->createPageInfo($edgeMap, $arguments);
}
private function createConnectionArguments(array $arguments = []): ConnectionArguments
{
return ConnectionArguments::fromArray($arguments);
}
private function createEdgeMap(array $data, ConnectionArguments $arguments): array
{
return \array_reduce($data, function ($data, $item) use ($arguments) {
$product = new Product($item['id'], $item['title'], $item['description'], $item['createdAt']);
$cursor = base64_encode($product->createCursor($arguments));
$edge = new Edge($cursor, $product);
$data[$cursor] = $edge;
return $data;
}, []);
}
private function createPageInfo($edgeMap, $arguments, $total = null): PageInfo
{
return (new ArrayPageInfoBuilder($edgeMap, $arguments, $total))->build();
}
}
class Product implements NodeInterface
{
public $id;
public $title;
public $description;
public $createdAt;
/**
* Product constructor.
*
* @param $id
* @param $title
* @param $description
* @param $createdAt
*/
public function __construct($id, $title, $description, $createdAt)
{
$this->id = $id;
$this->title = $title;
$this->description = $description;
$this->createdAt = $createdAt;
}
/**
* @param ConnectionArguments $arguments
* @return string
*/
public function createCursor(ConnectionArguments $arguments): string
{
return $this->createdAt;
}
}
<?php
namespace Digia\GraphQL\Relay;
class ConnectionArguments
{
/**
* @var string|null
*/
protected $after;
/**
* @var string|null
*/
protected $before;
/**
* @var int|null
*/
protected $first;
/**
* @var int|null
*/
protected $last;
/**
* ConnectionArguments constructor.
*
* @param null|string $after
* @param null|string $before
* @param int|null $first
* @param int|null $last
*/
public function __construct(?string $after = null, ?string $before = null, ?int $first = null, ?int $last = null)
{
$this->after = $after;
$this->before = $before;
$this->first = $first;
$this->last = $last;
}
/**
* @return null|string
*/
public function getAfter(): ?string
{
return $this->after;
}
/**
* @return null|string
*/
public function getBefore(): ?string
{
return $this->before;
}
/**
* @return int|null
*/
public function getFirst(): ?int
{
return $this->first;
}
/**
* @return int|null
*/
public function getLast(): ?int
{
return $this->last;
}
/**
* @param array $arguments
* @return ConnectionArguments
*/
public static function fromArray(array $arguments): ConnectionArguments
{
return new ConnectionArguments(
$arguments['after'] ?? null,
$arguments['before'] ?? null,
$arguments['first'] ?? null,
$arguments['last'] ?? null
);
}
}
<?php
namespace Digia\GraphQL\Relay;
interface ConnectionInterface
{
/**
* @return iterable
*/
public function getEdges(): iterable;
/**
* @return PageInfo
*/
public function getPageInfo(): PageInfo;
}
<?php
namespace ALehdet\AspaApi\GraphQL;
interface DataFetcherInterface
{
/**
* @param int $first
* @param string $after
* @return iterable
*/
public function fetchAfter(int $first, string $after): iterable;
/**
* @param int $last
* @param string $before
* @return iterable
*/
public function fetchBefore(int $last, string $before): iterable;
/**
* @param int $first
* @return iterable
*/
public function fetchFirst(int $first): iterable;
/**
* @param int $last
* @return iterable
*/
public function fetchLast(int $last): iterable;
}
<?php
namespace ALehdet\AspaApi\GraphQL;
use Digia\GraphQL\Relay\ConnectionArguments;
use Digia\GraphQL\Relay\ConnectionInterface;
use Digia\GraphQL\Relay\Edge;
use Digia\GraphQL\Relay\NodeInterface;
use Digia\GraphQL\Relay\PageInfo;
use Digia\GraphQL\Relay\RelayException;
class DoctrineConnection implements ConnectionInterface
{
/**
* @var DataFetcherInterface
*/
protected $dataFetcher;
/**
* @var ConnectionArguments
*/
protected $arguments;
/**
* @var Edge[]
*/
protected $edges;
/**
* @var PageInfo
*/
protected $pageInfo;
/**
* DoctrineConnection constructor.
*
* @param DataFetcherInterface $dataFetcher
* @param ConnectionArguments $arguments
* @throws RelayException
*/
public function __construct(DataFetcherInterface $dataFetcher, ConnectionArguments $arguments)
{
$this->dataFetcher = $dataFetcher;
$this->arguments = $arguments;
$this->edges = $this->createEdges();
$this->pageInfo = $this->createPageInfo();
}
/**
* @return iterable
* @throws RelayException
*/
public function getEdges(): iterable
{
return $this->edges;
}
/**
* @return PageInfo
*/
public function getPageInfo(): PageInfo
{
return $this->pageInfo;
}
/**
* @return Edge[]
* @throws RelayException
*/
protected function createEdges(): array
{
return \array_reduce($this->fetchData(), function ($data, NodeInterface $node) {
$cursor = $this->encodeCursor($node->createCursor($this->arguments));
$data[] = new Edge($cursor, $node);
return $data;
}, []);
}
/**
* @return PageInfo
*/
protected function createPageInfo(): PageInfo
{
return (new DoctrinePageInfoBuilder($this->edges, $this->arguments))->build();
}
/**
* @return array
* @throws RelayException
*/
protected function fetchData(): array
{
$first = $this->arguments->getFirst();
if (null !== $first) {
$after = $this->arguments->getAfter();
return null !== $after
? $this->dataFetcher->fetchAfter($first, $this->decodeCursor($after))
: $this->dataFetcher->fetchFirst($first);
}
$last = $this->arguments->getLast();
if (null !== $last) {
$before = $this->arguments->getBefore();
return null !== $before
? $this->dataFetcher->fetchBefore($last, $this->decodeCursor($before))
: $this->dataFetcher->fetchLast($last);
}
throw new RelayException('You must provide a `first` or `last` value to properly paginate connections.');
}
/**
* @param string $cursor
* @return string
*/
protected function encodeCursor(string $cursor): string
{
return base64_encode($cursor);
}
/**
* @param string $cursor
* @return string
*/
protected function decodeCursor(string $cursor): string
{
return base64_decode($cursor);
}
}
<?php
namespace ALehdet\AspaApi\GraphQL;
use ALehdet\AspaApi\GraphQL\Relay\AbstractPageBuilder;
use Digia\GraphQL\Relay\ConnectionArguments;
use Digia\GraphQL\Relay\Edge;
class DoctrinePageInfoBuilder extends AbstractPageBuilder
{
/**
* @var Edge[]
*/
protected $edges;
/**
* @var ConnectionArguments
*/
protected $arguments;
/**
* DoctrinePageInfoBuilder constructor.
*
* @param Edge[] $edges
* @param ConnectionArguments $arguments
*/
public function __construct(array $edges, ConnectionArguments $arguments)
{
$this->edges = $edges;
$this->arguments = $arguments;
}
/**
* @inheritdoc
*/
public function getFirstEdge(): ?Edge
{
return $this->edges[0] ?? null;
}
/**
* @inheritdoc
*/
public function getLastEdge(): ?Edge
{
return $this->edges[\count($this->edges) - 1] ?? null;
}
}
<?php
namespace Digia\GraphQL\Relay;
class Edge
{
/**
* @var string
*/
protected $cursor;
/**
* @var NodeInterface
*/
protected $node;
/**
* Edge constructor.
*
* @param string $cursor
* @param NodeInterface $node
*/
public function __construct(string $cursor, NodeInterface $node)
{
$this->cursor = $cursor;
$this->node = $node;
}
/**
* @return string
*/
public function getCursor(): string
{
return $this->cursor;
}
/**
* @return NodeInterface
*/
public function getNode(): NodeInterface
{
return $this->node;
}
}
<?php
namespace Digia\GraphQL\Relay;
interface NodeInterface
{
/**
* @param ConnectionArguments $arguments
* @return string
*/
public function createCursor(ConnectionArguments $arguments): string;
}
<?php
namespace Digia\GraphQL\Relay;
interface PageInfoBuilderInterface
{
/**
* @return Edge|null
*/
public function getFirstEdge(): ?Edge;
/**
* @return Edge|null
*/
public function getLastEdge(): ?Edge;
/**
* @return bool
*/
public function hasPreviousPage(): bool;
/**
* @return bool
*/
public function hasNextPage(): bool;
}
<?php
namespace Digia\GraphQL\Relay;
class PageInfo
{
/**
* @var string|null
*/
protected $startCursor;
/**
* @var string|null
*/
protected $endCursor;
/**
* @var bool
*/
protected $hasPreviousPage;
/**
* @var bool
*/
protected $hasNextPage;
/**
* PageInfo constructor.
*
* @param null|string $startCursor
* @param null|string $endCursor
* @param bool $hasPreviousPage
* @param bool $hasNextPage
*/
public function __construct(
?string $startCursor = null,
?string $endCursor = null,
bool $hasPreviousPage = false,
bool $hasNextPage = false
) {
$this->startCursor = $startCursor;
$this->endCursor = $endCursor;
$this->hasPreviousPage = $hasPreviousPage;
$this->hasNextPage = $hasNextPage;
}
/**
* @return null|string
*/
public function getStartCursor(): ?string
{
return $this->startCursor;
}
/**
* @return null|string
*/
public function getEndCursor(): ?string
{
return $this->endCursor;
}
/**
* @return bool
*/
public function getHasPreviousPage(): bool
{
return $this->hasPreviousPage;
}
/**
* @return bool
*/
public function getHasNextPage(): bool
{
return $this->hasNextPage;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment