diff --git a/.gitignore b/.gitignore index 2d648e3..e2e0588 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ infection.html /tmp/ /docs/.npm/ /docs/.config/ -composer.lock \ No newline at end of file +composer.lock +/.idea \ No newline at end of file diff --git a/docs/constraint.md b/docs/constraint.md index c687db2..88d0a3b 100644 --- a/docs/constraint.md +++ b/docs/constraint.md @@ -1,125 +1,30 @@ # Constraint -Constraints are used to enforce specific data schemas within a database. -There are two different types of constraints, one for nodes and one for relationships. -They can be created as following: +Constraints are entities which contain the following attributes: -```php -use Syndesi\CypherDataStructures\Type\NodeConstraint; -use Syndesi\CypherDataStructures\Type\RelationConstraint; - -$nodeConstraint = new NodeConstraint(); -$relationConstraint = new RelationConstraint(); - -// note: the later examples use $someConstraint when the specific type does not matter -``` - -!> **Important**: Constraints are not part of the OpenCypher specification and are different for each database type. - -!> **Note**: The creation of constraints might create internal indexes as well. +- **ConstraintName**: Zero or one constraint name, usually one. They are basically strings with validation. They must be + snake_case as per [Neo4j's examples](https://neo4j.com/docs/cypher-manual/current/constraints/examples/). + Constraint names can start with a single underscore, although this is reserved for internal logic. + You can overwrite the validation part by creating your own implementation of + `Syndesi\CypherDataStructures\Contract\ConstraintNameInterface`. +- **ConstraintType**: Defines how the constraint works, must be set manually. +- **For**: Can be either a `NodeLabel` or `RelationType`. +- **Properties**: Properties on which the constraint applies to. At least one is required. +- **Options**: Options which configure constraint dependant settings, usually empty. -## Name - -The name of constraints must be unique across the whole database. -The name is usually written in [lowercase snake case](https://neo4j.com/docs/cypher-manual/current/constraints/examples/). +## Examples ```php -// set the name of a constraint: -$someConstraint->setName('some_name'); - -// get the name of a constraint: -$someConstraint->getName(); -``` - -## For - -Constraints are always created for a specific node or relationship label/type. - -```php -// set the node label for a node label constraint: -$nodeConstraint->setFor('NodeLabel'); - -// set the relationship type for a relationship constraint: -$relationConstraint->setFor('RELATIONSHIP_TYPE'); - -// get the node label or relationship type from a constraint, depending on the constraint type: -$someConstraint->getFor(); -``` - -## Type - -Constraints have a specific type, e.g. `UNIQUE`. These types depend on the database as well as the database version. - -!> **Important**: Depending on the database, not all constraint types are available for nodes as well as relationships. - -```php -// set the type of constraint: -$someConstraint->setType('UNIQUE'); - -// get the type of constraint: -$someConstraint->getType(); -``` - -## Properties - -Constraints can specify the properties of a node/relationship on which they should act. - -!> **Note**: Most of the time only the property names are important. Setting the property values to null is therefore -ok. - -```php -// add property to a constraint with default value null: -$someConstraint->addProperty('propertyName'); - -// add multiple properties to a constraint: -$someConstraint->addProperties([ - 'id' => null, - 'hello' => 'world :D' -]); - -// check if a constraint has a specific property: -$someConstraint->hasProperty('id'); - -// get the value of a specific property: -$someConstraint->getProperty('id'); - -// get all properties from a constraint: -$someConstraint->getProperties(); - -// remove a specific property from a constraint: -$someConstraint->removeProperty('hello'); - -// remove all properties from a constraint: -$someConstraint->removeProperties(); -``` - -## Options - -Some constraints can be configured via options. -The example uses strings, but all types (arrays, integers etc.) are supported. - -```php -// add a single option -$someConstraint->addOption('name', 'value'); - -// add multiple options -$someConstraint->addOptions([ - 'other.name' => 'other value', - 'some.name' => 'some value' -]); - -// check if constraint has option -$someConstraint->hasOption('name'); - -// get specific option from a constraint -$someConstraint->getOption('name'); - -// get all options from a constraint -$someConstraint->getOptions(); - -// remove a specific option from a constraint: -$someConstraint->removeOption('name'); - -// remove all options from a constraint: -$someConstraint->removeOptions(); +use Syndesi\CypherDataStructures\Type\ConstraintName; +use Syndesi\CypherDataStructures\Type\Constraint; +use Syndesi\CypherDataStructures\Type\ConstraintType; +use Syndesi\CypherDataStructures\Type\NodeLabel; +use Syndesi\CypherDataStructures\Type\PropertyName; + +$constraint = new Constraint(); +$constraint + ->setConstraintName(new ConstraintName('some_name')) + ->setConstraintType(ConstraintType::UNIQUE) + ->setFor(new NodeLabel('SomeNode')) + ->addProperty(new PropertyName('id')); ``` diff --git a/src/Contract/DateTimeConvertible.php b/src/Contract/DateTimeConvertible.php new file mode 100644 index 0000000..8bf2d0c --- /dev/null +++ b/src/Contract/DateTimeConvertible.php @@ -0,0 +1,12 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Contract\OGM; + +use BadMethodCallException; +use Syndesi\CypherDataStructures\Type\OGM\Dictionary; + +/** + * Defines how an object with properties should behave. + * + * @psalm-immutable + * + * @template T + */ +interface HasPropertiesInterface +{ + /** + * Returns the properties a map. + * + * @return Dictionary + */ + public function getProperties(): Dictionary; + + /** + * @param string $name + * + * @return T + */ + public function __get($name); + + /** + * Always throws an exception as cypher objects are immutable. + * + * @param string $name + * @param T $value + * + * @throws BadMethodCallException + */ + public function __set($name, $value): void; + + /** + * Checks to see if the property exists and is set. + * + * @param string $name + */ + public function __isset($name): bool; +} diff --git a/src/Contract/OGM/PointInterface.php b/src/Contract/OGM/PointInterface.php new file mode 100644 index 0000000..ee2c75a --- /dev/null +++ b/src/Contract/OGM/PointInterface.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Contract\OGM; + +/** + * Defines a basic Point type in neo4j. + * + * @psalm-immutable + * + * @psalm-type Crs = 'wgs-84'|'wgs-84-3d'|'cartesian'|'cartesian-3d'; + */ +interface PointInterface +{ + /** + * Returns the x coordinate. + */ + public function getX(): float; + + /** + * Returns the y coordinate. + */ + public function getY(): float; + + /** + * Returns the Coordinates Reference System. + * + * @see https://en.wikipedia.org/wiki/Spatial_reference_system + * + * @return Crs + */ + public function getCrs(): string; + + /** + * Returns the spacial reference identifier. + * + * @see https://en.wikipedia.org/wiki/Spatial_reference_system + */ + public function getSrid(): int; +} diff --git a/src/Contract/PackstreamConvertible.php b/src/Contract/PackstreamConvertible.php new file mode 100644 index 0000000..22fc108 --- /dev/null +++ b/src/Contract/PackstreamConvertible.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Exception; + +use RuntimeException; + +/** + * Exception when accessing a property which does not exist. + * + * @psalm-immutable + * + * @psalm-suppress MutableDependency + */ +final class PropertyDoesNotExistException extends RuntimeException +{ +} diff --git a/src/Exception/RuntimeTypeException.php b/src/Exception/RuntimeTypeException.php new file mode 100644 index 0000000..9b6f16b --- /dev/null +++ b/src/Exception/RuntimeTypeException.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Exception; + +use RuntimeException; + +use function get_debug_type; + +final class RuntimeTypeException extends RuntimeException +{ + public function __construct(mixed $value, string $type) + { + $actualType = get_debug_type($value); + $message = sprintf('Cannot cast %s to type: %s', $actualType, $type); + parent::__construct($message); + } +} diff --git a/src/Type/ArrayList.php b/src/Type/ArrayList.php new file mode 100644 index 0000000..f312a42 --- /dev/null +++ b/src/Type/ArrayList.php @@ -0,0 +1,257 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Type; + +use AppendIterator; +use ArrayIterator; +use Generator; +use OutOfBoundsException; +use Syndesi\CypherDataStructures\Exception\RuntimeTypeException; +use Syndesi\CypherDataStructures\Type\OGM\AbstractCypherSequence; +use Syndesi\CypherDataStructures\TypeCaster; + +use function array_values; +use function is_array; +use function is_callable; +use function is_iterable; + +/** + * An immutable ordered sequence of items. + * + * @template TValue + * + * @extends AbstractCypherSequence + */ +class ArrayList extends AbstractCypherSequence +{ + /** + * @param iterable|callable():Generator $iterable + * + * @psalm-mutation-free + */ + public function __construct($iterable = []) + { + if (is_array($iterable)) { + /** @var array $iterable */ + $this->keyCache = count($iterable) === 0 ? [] : range(0, count($iterable) - 1); + $this->cache = array_values($iterable); + $this->generator = new ArrayIterator([]); + $this->generatorPosition = count($this->keyCache); + } else { + $this->generator = static function () use ($iterable): Generator { + $i = 0; + /** @var Generator $it */ + $it = is_callable($iterable) ? $iterable() : $iterable; + foreach ($it as $value) { + yield $i => $value; + ++$i; + } + }; + } + } + + /** + * @template Value + * + * @param callable():(\Generator) $operation + * + * @return static + * + * @psalm-mutation-free + */ + protected function withOperation($operation): AbstractCypherSequence + { + /** @psalm-suppress UnsafeInstantiation */ + return new static($operation); + } + + /** + * Returns the first element in the sequence. + * + * @return TValue + */ + public function first() + { + foreach ($this as $value) { + return $value; + } + + throw new OutOfBoundsException('Cannot grab first element of an empty list'); + } + + /** + * Returns the last element in the sequence. + * + * @return TValue + */ + public function last() + { + if ($this->isEmpty()) { + throw new OutOfBoundsException('Cannot grab last element of an empty list'); + } + + $array = $this->toArray(); + + return $array[count($array) - 1]; + } + + /** + * @template NewValue + * + * @param iterable $values + * + * @return static + * + * @psalm-mutation-free + */ + public function merge($values): ArrayList + { + return $this->withOperation(function () use ($values): Generator { + $iterator = new AppendIterator(); + + $iterator->append($this); + $iterator->append(new self($values)); + + yield from $iterator; + }); + } + + /** + * Gets the nth element in the list. + * + * @throws OutOfBoundsException + * + * @return TValue + */ + public function get(int $key) + { + return $this->offsetGet($key); + } + + public function getAsString(int $key): string + { + $value = $this->get($key); + $tbr = TypeCaster::toString($value); + if ($tbr === null) { + throw new RuntimeTypeException($value, 'string'); + } + + return $tbr; + } + + public function getAsInt(int $key): int + { + $value = $this->get($key); + $tbr = TypeCaster::toInt($value); + if ($tbr === null) { + throw new RuntimeTypeException($value, 'int'); + } + + return $tbr; + } + + public function getAsFloat(int $key): float + { + $value = $this->get($key); + $tbr = TypeCaster::toFloat($value); + if ($tbr === null) { + throw new RuntimeTypeException($value, 'float'); + } + + return $tbr; + } + + public function getAsBool(int $key): bool + { + $value = $this->get($key); + $tbr = TypeCaster::toBool($value); + if ($tbr === null) { + throw new RuntimeTypeException($value, 'bool'); + } + + return $tbr; + } + + /** + * @return null + */ + public function getAsNull(int $key) + { + /** @psalm-suppress UnusedMethodCall */ + $this->get($key); + + return TypeCaster::toNull(); + } + + /** + * @template U + * + * @param class-string $class + * + * @return U + */ + public function getAsObject(int $key, string $class): object + { + $value = $this->get($key); + $tbr = TypeCaster::toClass($value, $class); + if ($tbr === null) { + throw new RuntimeTypeException($value, $class); + } + + return $tbr; + } + + /** + * @return Map + */ + public function getAsMap(int $key): Map + { + $value = $this->get($key); + if (!is_iterable($value)) { + throw new RuntimeTypeException($value, Map::class); + } + + /** @psalm-suppress MixedArgumentTypeCoercion */ + return new Map($value); + } + + /** + * @return ArrayList + */ + public function getAsArrayList(int $key): ArrayList + { + $value = $this->get($key); + if (!is_iterable($value)) { + throw new RuntimeTypeException($value, self::class); + } + + /** @psalm-suppress MixedArgumentTypeCoercion */ + return new ArrayList($value); + } + + /** + * @template Value + * + * @param iterable $iterable + * + * @return static + * + * @pure + */ + public static function fromIterable(iterable $iterable): ArrayList + { + /** @psalm-suppress UnsafeInstantiation */ + return new static($iterable); + } +} diff --git a/src/Type/Map.php b/src/Type/Map.php new file mode 100644 index 0000000..000ac57 --- /dev/null +++ b/src/Type/Map.php @@ -0,0 +1,502 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Type; + +use ArrayIterator; +use Generator; +use OutOfBoundsException; +use stdClass; +use Syndesi\CypherDataStructures\Exception\RuntimeTypeException; +use Syndesi\CypherDataStructures\Type\OGM\AbstractCypherSequence; +use Syndesi\CypherDataStructures\TypeCaster; + +use function array_key_exists; +use function array_key_last; +use function count; +use function func_num_args; +use function is_array; +use function is_callable; +use function is_iterable; +use function sprintf; + +/** + * An immutable ordered map of items. + * + * @template TValue + * + * @extends AbstractCypherSequence + */ +class Map extends AbstractCypherSequence +{ + /** + * @param iterable|callable():\Generator $iterable + * + * @psalm-mutation-free + */ + public function __construct($iterable = []) + { + if (is_array($iterable)) { + $i = 0; + foreach ($iterable as $key => $value) { + if (!$this->isStringable($key)) { + $key = (string) $i; + } + /** @var string $key */ + $this->keyCache[] = $key; + /** @var TValue $value */ + $this->cache[$key] = $value; + ++$i; + } + /** @var ArrayIterator */ + $it = new ArrayIterator([]); + $this->generator = $it; + $this->generatorPosition = count($this->keyCache); + } else { + $this->generator = function () use ($iterable): Generator { + $i = 0; + /** @var Generator $it */ + $it = is_callable($iterable) ? $iterable() : $iterable; + /** @var mixed $key */ + foreach ($it as $key => $value) { + if ($this->isStringable($key)) { + yield (string) $key => $value; + } else { + yield (string) $i => $value; + } + ++$i; + } + }; + } + } + + /** + * @template Value + * + * @param callable():(\Generator) $operation + * + * @return static + * + * @psalm-mutation-free + */ + protected function withOperation($operation): Map + { + /** @psalm-suppress UnsafeInstantiation */ + return new static($operation); + } + + /** + * Returns the first pair in the map. + * + * @return Pair + */ + public function first(): Pair + { + foreach ($this as $key => $value) { + return new Pair($key, $value); + } + throw new OutOfBoundsException('Cannot grab first element of an empty map'); + } + + /** + * Returns the last pair in the map. + * + * @return Pair + */ + public function last(): Pair + { + $array = $this->toArray(); + if (count($array) === 0) { + throw new OutOfBoundsException('Cannot grab last element of an empty map'); + } + + $key = array_key_last($array); + + return new Pair($key, $array[$key]); + } + + /** + * Returns the pair at the nth position of the map. + * + * @return Pair + */ + public function skip(int $position): Pair + { + $i = 0; + foreach ($this as $key => $value) { + if ($i === $position) { + return new Pair($key, $value); + } + ++$i; + } + + throw new OutOfBoundsException(sprintf('Cannot skip to a pair at position: %s', $position)); + } + + /** + * Returns the keys in the map in order. + * + * @return ArrayList + * + * @psalm-suppress UnusedForeachValue + */ + public function keys(): ArrayList + { + return ArrayList::fromIterable((function () { + foreach ($this as $key => $value) { + yield $key; + } + })()); + } + + /** + * Returns the pairs in the map in order. + * + * @return ArrayList> + */ + public function pairs(): ArrayList + { + return ArrayList::fromIterable((function () { + foreach ($this as $key => $value) { + yield new Pair($key, $value); + } + })()); + } + + /** + * Create a new map sorted by keys. Natural ordering will be used if no comparator is provided. + * + * @param (callable(string, string):int)|null $comparator + * + * @return static + */ + public function ksorted(callable $comparator = null): Map + { + return $this->withOperation(function () use ($comparator) { + $pairs = $this->pairs()->sorted(static function (Pair $x, Pair $y) use ($comparator) { + if ($comparator) { + return $comparator($x->getKey(), $y->getKey()); + } + + return $x->getKey() <=> $y->getKey(); + }); + + foreach ($pairs as $pair) { + yield $pair->getKey() => $pair->getValue(); + } + }); + } + + /** + * Returns the values in the map in order. + * + * @return ArrayList + */ + public function values(): ArrayList + { + return ArrayList::fromIterable((function () { + yield from $this; + })()); + } + + /** + * Creates a new map using exclusive or on the keys. + * + * @param iterable $map + * + * @return static + */ + public function xor(iterable $map): Map + { + return $this->withOperation(function () use ($map) { + $map = Map::fromIterable($map); + foreach ($this as $key => $value) { + if (!$map->hasKey($key)) { + yield $key => $value; + } + } + + foreach ($map as $key => $value) { + if (!$this->hasKey($key)) { + yield $key => $value; + } + } + }); + } + + /** + * @template NewValue + * + * @param iterable $values + * + * @return static + * + * @psalm-mutation-free + */ + public function merge(iterable $values): Map + { + return $this->withOperation(function () use ($values) { + $tbr = $this->toArray(); + $values = Map::fromIterable($values); + + foreach ($values as $key => $value) { + $tbr[$key] = $value; + } + + yield from $tbr; + }); + } + + /** + * Creates a union of this and the provided map. The items in the original map take precedence. + * + * @param iterable $map + * + * @return static + */ + public function union(iterable $map): Map + { + return $this->withOperation(function () use ($map) { + $map = Map::fromIterable($map)->toArray(); + $x = $this->toArray(); + + yield from $x; + + foreach ($map as $key => $value) { + if (!array_key_exists($key, $x)) { + yield $key => $value; + } + } + }); + } + + /** + * Creates a new map from the existing one filtering the values based on the keys that don't exist in the provided map. + * + * @param iterable $map + * + * @return static + */ + public function intersect(iterable $map): Map + { + return $this->withOperation(function () use ($map) { + $map = Map::fromIterable($map)->toArray(); + foreach ($this as $key => $value) { + if (array_key_exists($key, $map)) { + yield $key => $value; + } + } + }); + } + + /** + * Creates a new map from the existing one filtering the values based on the keys that also exist in the provided map. + * + * @param iterable $map + * + * @return static + */ + public function diff(iterable $map): Map + { + return $this->withOperation(function () use ($map) { + $map = Map::fromIterable($map)->toArray(); + foreach ($this as $key => $value) { + if (!array_key_exists($key, $map)) { + yield $key => $value; + } + } + }); + } + + /** + * Gets the value with the provided key. If a default value is provided, it will return the default instead of throwing an error when the key does not exist. + * + * @template TDefault + * + * @param TDefault $default + * + * @throws OutOfBoundsException + * + * @return (func_num_args() is 1 ? TValue : TValue|TDefault) + */ + public function get(string $key, $default = null) + { + if (!$this->offsetExists($key)) { + if (func_num_args() === 1) { + throw new OutOfBoundsException(sprintf('Cannot get item in sequence with key: %s', $key)); + } + + return $default; + } + + return $this->offsetGet($key); + } + + public function jsonSerialize() + { + if ($this->isEmpty()) { + return new stdClass(); + } + + return parent::jsonSerialize(); + } + + public function getAsString(string $key, mixed $default = null): string + { + if (func_num_args() === 1) { + $value = $this->get($key); + } else { + /** @var mixed */ + $value = $this->get($key, $default); + } + $tbr = TypeCaster::toString($value); + if ($tbr === null) { + throw new RuntimeTypeException($value, 'string'); + } + + return $tbr; + } + + public function getAsInt(string $key, mixed $default = null): int + { + if (func_num_args() === 1) { + $value = $this->get($key); + } else { + /** @var mixed */ + $value = $this->get($key, $default); + } + $tbr = TypeCaster::toInt($value); + if ($tbr === null) { + throw new RuntimeTypeException($value, 'int'); + } + + return $tbr; + } + + public function getAsFloat(string $key, mixed $default = null): float + { + if (func_num_args() === 1) { + $value = $this->get($key); + } else { + /** @var mixed */ + $value = $this->get($key, $default); + } + $tbr = TypeCaster::toFloat($value); + if ($tbr === null) { + throw new RuntimeTypeException($value, 'float'); + } + + return $tbr; + } + + public function getAsBool(string $key, mixed $default = null): bool + { + if (func_num_args() === 1) { + $value = $this->get($key); + } else { + /** @var mixed */ + $value = $this->get($key, $default); + } + $tbr = TypeCaster::toBool($value); + if ($tbr === null) { + throw new RuntimeTypeException($value, 'bool'); + } + + return $tbr; + } + + /** + * @return null + */ + public function getAsNull(string $key, mixed $default = null) + { + if (func_num_args() === 1) { + /** @psalm-suppress UnusedMethodCall */ + $this->get($key); + } + + return TypeCaster::toNull(); + } + + /** + * @template U + * + * @param class-string $class + * + * @return U + */ + public function getAsObject(string $key, string $class, mixed $default = null): object + { + if (func_num_args() === 1) { + $value = $this->get($key); + } else { + /** @var mixed */ + $value = $this->get($key, $default); + } + $tbr = TypeCaster::toClass($value, $class); + if ($tbr === null) { + throw new RuntimeTypeException($value, $class); + } + + return $tbr; + } + + /** + * @return Map + */ + public function getAsMap(string $key, mixed $default = null): Map + { + if (func_num_args() === 1) { + $value = $this->get($key); + } else { + /** @var mixed */ + $value = $this->get($key, $default); + } + + if (!is_iterable($value)) { + throw new RuntimeTypeException($value, self::class); + } + + return new Map($value); + } + + /** + * @return ArrayList + */ + public function getAsArrayList(string $key, mixed $default = null): ArrayList + { + if (func_num_args() === 1) { + $value = $this->get($key); + } else { + /** @var mixed */ + $value = $this->get($key, $default); + } + if (!is_iterable($value)) { + throw new RuntimeTypeException($value, ArrayList::class); + } + + return new ArrayList($value); + } + + /** + * @template Value + * + * @param iterable $iterable + * + * @return Map + */ + public static function fromIterable(iterable $iterable): Map + { + return new self($iterable); + } +} diff --git a/src/Type/OGM/Abstract3DPoint.php b/src/Type/OGM/Abstract3DPoint.php new file mode 100644 index 0000000..71216e5 --- /dev/null +++ b/src/Type/OGM/Abstract3DPoint.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Type\OGM; + +use Syndesi\CypherDataStructures\Contract\OGM\PointInterface; +use Syndesi\CypherDataStructures\Contract\PackstreamConvertible; + +/** + * A cartesian point in three dimensional space. + * + * @see https://neo4j.com/docs/cypher-manual/current/functions/spatial/#functions-point-cartesian-3d + * + * @psalm-immutable + * + * @psalm-import-type Crs from PointInterface + */ +abstract class Abstract3DPoint extends AbstractPoint implements PointInterface, PackstreamConvertible +{ + public function __construct(float $x, float $y, private float $z) + { + parent::__construct($x, $y); + } + + public function getZ(): float + { + return $this->z; + } + + /** + * @return array{x: float, y: float, z: float, srid: int, crs: Crs} + */ + public function toArray(): array + { + $tbr = parent::toArray(); + + $tbr['z'] = $this->z; + + return $tbr; + } + + public function getPackstreamMarker(): int + { + return 0x59; + } +} diff --git a/src/Type/OGM/AbstractCypherObject.php b/src/Type/OGM/AbstractCypherObject.php new file mode 100644 index 0000000..b36e4eb --- /dev/null +++ b/src/Type/OGM/AbstractCypherObject.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Type\OGM; + +use Syndesi\CypherDataStructures\Contract\PackstreamConvertible; +use function array_key_exists; +use ArrayAccess; +use ArrayIterator; +use BadMethodCallException; +use IteratorAggregate; +use JsonSerializable; +use OutOfBoundsException; +use function sprintf; +use Traversable; + +/** + * Abstract immutable container with basic functionality to integrate easily into the driver ecosystem. + * + * @template TKey of array-key + * @template TValue + * + * @implements ArrayAccess + * @implements IteratorAggregate + * + * @psalm-immutable + */ +abstract class AbstractCypherObject implements JsonSerializable, ArrayAccess, IteratorAggregate, PackstreamConvertible +{ + /** + * Represents the container as an array. + * + * @return array + */ + abstract public function toArray(): array; + + public function jsonSerialize() + { + return $this->toArray(); + } + + /** + * @return Traversable + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->toArray()); + } + + /** + * @param TKey $offset + */ + public function offsetExists($offset): bool + { + return array_key_exists($offset, $this->toArray()); + } + + /** + * @param TKey $offset + * + * @return TValue + */ + public function offsetGet($offset) + { + $serialized = $this->toArray(); + if (!array_key_exists($offset, $serialized)) { + throw new OutOfBoundsException("Offset: \"$offset\" does not exists in object of instance: ".static::class); + } + + return $serialized[$offset]; + } + + /** + * @param TKey $offset + * @param TValue $value + */ + final public function offsetSet($offset, $value): void + { + throw new BadMethodCallException(sprintf('%s is immutable', static::class)); + } + + /** + * @param TKey $offset + */ + final public function offsetUnset($offset): void + { + throw new BadMethodCallException(sprintf('%s is immutable', static::class)); + } +} diff --git a/src/Type/OGM/AbstractCypherSequence.php b/src/Type/OGM/AbstractCypherSequence.php new file mode 100644 index 0000000..11bbae0 --- /dev/null +++ b/src/Type/OGM/AbstractCypherSequence.php @@ -0,0 +1,558 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Type\OGM; + +use ArrayAccess; +use ArrayIterator; +use BadMethodCallException; +use Countable; +use Iterator; +use JsonSerializable; +use OutOfBoundsException; +use ReturnTypeWillChange; +use Syndesi\CypherDataStructures\Type\ArrayList; +use Syndesi\CypherDataStructures\Type\Map; +use UnexpectedValueException; + +use function array_key_exists; +use function array_reverse; +use function call_user_func; +use function count; +use function get_object_vars; +use function implode; +use function is_array; +use function is_callable; +use function is_numeric; +use function is_object; +use function is_string; +use function method_exists; +use function property_exists; +use function sprintf; + +use const INF; +use const PHP_INT_MAX; + +/** + * Abstract immutable sequence with basic functional methods. + * + * @template TValue + * @template TKey of array-key + * + * @implements ArrayAccess + * @implements Iterator + */ +abstract class AbstractCypherSequence implements Countable, JsonSerializable, ArrayAccess, Iterator +{ + /** @var list */ + protected array $keyCache = []; + /** @var array */ + protected array $cache = []; + private int $cacheLimit = PHP_INT_MAX; + protected int $currentPosition = 0; + protected int $generatorPosition = 0; + + /** + * @var (callable():(\Iterator))|\Iterator + */ + protected $generator; + + /** + * @template Value + * + * @param callable():(\Generator) $operation + * + * @return static + * + * @psalm-mutation-free + */ + abstract protected function withOperation($operation): self; + + /** + * Copies the sequence. + * + * @return static + * + * @psalm-mutation-free + */ + final public function copy(): self + { + return $this->withOperation(function () { + yield from $this; + }); + } + + /**mixed + * Returns whether the sequence is empty. + * + * @psalm-suppress UnusedForeachValue + */ + final public function isEmpty(): bool + { + /** @noinspection PhpLoopNeverIteratesInspection */ + foreach ($this as $ignored) { + return false; + } + + return true; + } + + /** + * Creates a new sequence by merging this one with the provided iterable. When the iterable is not a list, the provided values will override the existing items in case of a key collision. + * + * @template NewValue + * + * @param iterable $values + * + * @return static + * + * @psalm-mutation-free + */ + abstract public function merge(iterable $values): self; + + /** + * Checks if the sequence contains the given key. + * + * @param TKey $key + */ + final public function hasKey($key): bool + { + return $this->offsetExists($key); + } + + /** + * Checks if the sequence contains the given value. The equality check is strict. + * + * @param TValue $value + */ + final public function hasValue($value): bool + { + return $this->find($value) !== false; + } + + /** + * Creates a filtered the sequence with the provided callback. + * + * @param callable(TValue, TKey):bool $callback + * + * @return static + * + * @psalm-mutation-free + */ + final public function filter(callable $callback): self + { + return $this->withOperation(function () use ($callback) { + foreach ($this as $key => $value) { + if ($callback($value, $key)) { + yield $key => $value; + } + } + }); + } + + /** + * Maps the values of this sequence to a new one with the provided callback. + * + * @template ReturnType + * + * @param callable(TValue, TKey):ReturnType $callback + * + * @return static + * + * @psalm-mutation-free + */ + final public function map(callable $callback): self + { + return $this->withOperation(function () use ($callback) { + foreach ($this as $key => $value) { + yield $key => $callback($value, $key); + } + }); + } + + /** + * Reduces this sequence with the given callback. + * + * @template TInitial + * + * @param callable(TInitial|null, TValue, TKey):TInitial $callback + * @param TInitial|null $initial + * + * @return TInitial + */ + final public function reduce(callable $callback, $initial = null) + { + foreach ($this as $key => $value) { + $initial = $callback($initial, $value, $key); + } + + return $initial; + } + + /** + * Finds the position of the value within the sequence. + * + * @param TValue $value + * + * @return false|TKey returns the key of the value if it is found, false otherwise + */ + final public function find($value) + { + foreach ($this as $i => $x) { + if ($value === $x) { + return $i; + } + } + + return false; + } + + /** + * Creates a reversed sequence. + * + * @return static + * + * @psalm-mutation-free + */ + public function reversed(): self + { + return $this->withOperation(function () { + yield from array_reverse($this->toArray()); + }); + } + + /** + * Slices a new sequence starting from the given offset with a certain length. + * If the length is null it will slice the entire remainder starting from the offset. + * + * @return static + * + * @psalm-mutation-free + */ + public function slice(int $offset, int $length = null): self + { + return $this->withOperation(function () use ($offset, $length) { + if ($length !== 0) { + $count = -1; + $length ??= INF; + foreach ($this as $key => $value) { + ++$count; + if ($count < $offset) { + continue; + } + + yield $key => $value; + if ($count === ($offset + $length - 1)) { + break; + } + } + } + }); + } + + /** + * Creates a sorted sequence. If the comparator is null it will use natural ordering. + * + * @param (callable(TValue, TValue):int)|null $comparator + * + * @return static + * + * @psalm-mutation-free + */ + public function sorted(?callable $comparator = null): self + { + return $this->withOperation(function () use ($comparator) { + $iterable = $this->toArray(); + + if ($comparator) { + uasort($iterable, $comparator); + } else { + asort($iterable); + } + + yield from $iterable; + }); + } + + /** + * Creates a list from the arrays and objects in the sequence whose values corresponding with the provided key. + * + * @return ArrayList + * + * @psalm-mutation-free + */ + public function pluck(string $key): ArrayList + { + return new ArrayList(function () use ($key) { + foreach ($this as $value) { + if ((is_array($value) && array_key_exists($key, $value)) || ($value instanceof ArrayAccess && $value->offsetExists($key))) { + yield $value[$key]; + } elseif (is_object($value) && property_exists($value, $key)) { + yield $value->$key; + } + } + }); + } + + /** + * Uses the values found at the provided key as the key for the new Map. + * + * @return Map + * + * @psalm-mutation-free + */ + public function keyBy(string $key): Map + { + return new Map(function () use ($key) { + foreach ($this as $value) { + if (((is_array($value) && array_key_exists($key, $value)) || ($value instanceof ArrayAccess && $value->offsetExists($key))) && $this->isStringable($value[$key])) { + yield $value[$key] => $value; + } elseif (is_object($value) && property_exists($value, $key) && $this->isStringable($value->$key)) { + yield $value->$key => $value; + } else { + throw new UnexpectedValueException('Cannot convert the value to a string'); + } + } + }); + } + + /** + * Joins the values within the sequence together with the provided glue. If the glue is null, it will be an empty string. + */ + public function join(?string $glue = null): string + { + /** @psalm-suppress MixedArgumentTypeCoercion */ + return implode($glue ?? '', $this->toArray()); + } + + /** + * Iterates over the sequence and applies the callable. + * + * @param callable(TValue, TKey):void $callable + * + * @return static + */ + public function each(callable $callable): self + { + foreach ($this as $key => $value) { + $callable($value, $key); + } + + return $this; + } + + #[ReturnTypeWillChange] + public function offsetGet($offset) + { + while (!array_key_exists($offset, $this->cache) && $this->valid()) { + $this->next(); + } + + if (!array_key_exists($offset, $this->cache)) { + throw new OutOfBoundsException(sprintf('Offset: "%s" does not exists in object of instance: %s', $offset, static::class)); + } + + return $this->cache[$offset]; + } + + public function offsetSet($offset, $value): void + { + throw new BadMethodCallException(sprintf('%s is immutable', static::class)); + } + + public function offsetUnset($offset): void + { + throw new BadMethodCallException(sprintf('%s is immutable', static::class)); + } + + /** + * @param TKey $offset + * + * @psalm-suppress UnusedForeachValue + */ + public function offsetExists($offset): bool + { + while (!array_key_exists($offset, $this->cache) && $this->valid()) { + $this->next(); + } + + return array_key_exists($offset, $this->cache); + } + + #[ReturnTypeWillChange] + public function jsonSerialize() + { + return $this->toArray(); + } + + /** + * Returns the sequence as an array. + * + * @return array + */ + final public function toArray(): array + { + $this->preload(); + + return $this->cache; + } + + /** + * Returns the sequence as an array. + * + * @return array + */ + final public function toRecursiveArray(): array + { + return $this->map(static function ($x) { + if ($x instanceof self) { + return $x->toRecursiveArray(); + } + + return $x; + })->toArray(); + } + + final public function count(): int + { + return count($this->toArray()); + } + + /** + * @return TValue + */ + #[ReturnTypeWillChange] + public function current() + { + $this->setupCache(); + + return $this->cache[$this->cacheKey()]; + } + + public function valid(): bool + { + return $this->currentPosition < $this->generatorPosition || $this->getGenerator()->valid(); + } + + public function rewind(): void + { + $this->currentPosition = max( + $this->currentPosition - $this->cacheLimit - 1, + 0 + ); + } + + public function next(): void + { + $generator = $this->getGenerator(); + if ($this->cache === []) { + $this->setupCache(); + } elseif ($this->currentPosition === $this->generatorPosition && $generator->valid()) { + $generator->next(); + + if ($generator->valid()) { + $this->keyCache[] = $generator->key(); + $this->cache[$generator->key()] = $generator->current(); + } + ++$this->generatorPosition; + ++$this->currentPosition; + } else { + ++$this->currentPosition; + } + } + + /** + * @return TKey + */ + #[ReturnTypeWillChange] + public function key() + { + return $this->cacheKey(); + } + + /** + * @return TKey + */ + protected function cacheKey() + { + return $this->keyCache[$this->currentPosition % max($this->cacheLimit - 1, 1)]; + } + + /** + * @return Iterator + */ + public function getGenerator(): Iterator + { + if (is_callable($this->generator)) { + $this->generator = call_user_func($this->generator); + } + + return $this->generator; + } + + /** + * @return static + */ + public function withCacheLimit(int $cacheLimit): self + { + $tbr = $this->copy(); + $tbr->cacheLimit = $cacheLimit; + + return $tbr; + } + + private function setupCache(): void + { + $generator = $this->getGenerator(); + + if (count($this->cache) % $this->cacheLimit === 0) { + $this->cache = []; + $this->keyCache = []; + } + + if ($this->cache === [] && $generator->valid()) { + $this->cache[$generator->key()] = $generator->current(); + $this->keyCache[] = $generator->key(); + } + } + + /** + * Preload the lazy evaluation. + */ + public function preload(): void + { + while ($this->valid()) { + $this->next(); + } + } + + /** + * @psalm-mutation-free + */ + protected function isStringable(mixed $key): bool + { + return is_string($key) || is_numeric($key) || (is_object($key) && method_exists($key, '__toString')); + } + + public function __serialize(): array + { + $this->preload(); + + $tbr = get_object_vars($this); + $tbr['generator'] = new ArrayIterator($this->cache); + $tbr['currentPosition'] = 0; + $tbr['generatorPosition'] = 0; + + return $tbr; + } +} diff --git a/src/Type/OGM/AbstractPoint.php b/src/Type/OGM/AbstractPoint.php new file mode 100644 index 0000000..199b97f --- /dev/null +++ b/src/Type/OGM/AbstractPoint.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Type\OGM; + +use Syndesi\CypherDataStructures\Contract\OGM\PointInterface; +use Syndesi\CypherDataStructures\Contract\PackstreamConvertible; + +/** + * A cartesian point in two dimensional space. + * + * @see https://neo4j.com/docs/cypher-manual/current/functions/spatial/#functions-point-cartesian-2d + * + * @psalm-immutable + * + * @psalm-import-type Crs from PointInterface + */ +abstract class AbstractPoint extends AbstractPropertyObject implements PointInterface, PackstreamConvertible +{ + public function __construct(private float $x, private float $y) + { + } + + abstract public function getCrs(): string; + + abstract public function getSrid(): int; + + public function getX(): float + { + return $this->x; + } + + public function getY(): float + { + return $this->y; + } + + public function getProperties(): Dictionary + { + /** @psalm-suppress InvalidReturnStatement False positive */ + return new Dictionary($this); + } + + /** + * @psalm-suppress ImplementedReturnTypeMismatch False positive + * + * @return array{x: float, y: float, crs: Crs, srid: int} + */ + public function toArray(): array + { + return [ + 'x' => $this->x, + 'y' => $this->y, + 'crs' => $this->getCrs(), + 'srid' => $this->getSrid(), + ]; + } + + public function getPackstreamMarker(): int + { + return 0x58; + } +} diff --git a/src/Type/OGM/AbstractPropertyObject.php b/src/Type/OGM/AbstractPropertyObject.php new file mode 100644 index 0000000..4e07896 --- /dev/null +++ b/src/Type/OGM/AbstractPropertyObject.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Type\OGM; + +use BadMethodCallException; +use function get_class; +use Syndesi\CypherDataStructures\Contract\OGM\HasPropertiesInterface; +use function sprintf; + +/** * + * @template PropertyTypes + * @template ObjectTypes + * + * @extends AbstractCypherObject + * @implements HasPropertiesInterface + * + * @psalm-immutable + */ +abstract class AbstractPropertyObject extends AbstractCypherObject implements HasPropertiesInterface +{ + public function __get($name) + { + /** @psalm-suppress ImpureMethodCall */ + return $this->getProperties()->get($name); + } + + public function __set($name, $value): void + { + throw new BadMethodCallException(sprintf('%s is immutable', $this::class)); + } + + public function __isset($name): bool + { + /** @psalm-suppress ImpureMethodCall */ + return $this->getProperties()->offsetExists($name); + } +} diff --git a/src/Type/OGM/Cartesian3DPoint.php b/src/Type/OGM/Cartesian3DPoint.php new file mode 100644 index 0000000..653b0d6 --- /dev/null +++ b/src/Type/OGM/Cartesian3DPoint.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Type\OGM; + +use Syndesi\CypherDataStructures\Contract\OGM\PointInterface; + +/** + * A cartesian point in three dimensional space. + * + * @see https://neo4j.com/docs/cypher-manual/current/functions/spatial/#functions-point-cartesian-3d + * + * @psalm-immutable + * + * @psalm-import-type Crs from PointInterface + */ +final class Cartesian3DPoint extends Abstract3DPoint implements PointInterface +{ + public const SRID = 9157; + public const CRS = 'cartesian-3d'; + + public function getSrid(): int + { + return self::SRID; + } + + public function getCrs(): string + { + return self::CRS; + } +} diff --git a/src/Type/OGM/CartesianPoint.php b/src/Type/OGM/CartesianPoint.php new file mode 100644 index 0000000..1674687 --- /dev/null +++ b/src/Type/OGM/CartesianPoint.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Type\OGM; + +use Syndesi\CypherDataStructures\Contract\OGM\PointInterface; + +/** + * A cartesian point in two dimensional space. + * + * @see https://neo4j.com/docs/cypher-manual/current/functions/spatial/#functions-point-cartesian-2d + * + * @psalm-immutable + * + * @psalm-import-type Crs from PointInterface + */ +final class CartesianPoint extends AbstractPoint implements PointInterface +{ + /** @var Crs */ + public const CRS = 'cartesian'; + public const SRID = 7203; + + public function getCrs(): string + { + return self::CRS; + } + + public function getSrid(): int + { + return self::SRID; + } +} diff --git a/src/Type/OGM/CypherList.php b/src/Type/OGM/CypherList.php new file mode 100644 index 0000000..1677d5f --- /dev/null +++ b/src/Type/OGM/CypherList.php @@ -0,0 +1,142 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Type\OGM; + +use Syndesi\CypherDataStructures\Contract\PackstreamConvertible; +use Syndesi\CypherDataStructures\Exception\RuntimeTypeException; +use Syndesi\CypherDataStructures\Type\ArrayList; +use Syndesi\CypherDataStructures\Type\Node; +use Syndesi\CypherDataStructures\TypeCaster; + +/** + * An immutable ordered sequence of items. + * + * @template TValue + * + * @extends ArrayList + */ +class CypherList extends ArrayList implements PackstreamConvertible +{ + /** + * @return Dictionary + */ + public function getAsCypherMap(int $key): Dictionary + { + $value = $this->get($key); + $tbr = TypeCaster::toCypherMap($value); + if ($tbr === null) { + throw new RuntimeTypeException($value, Dictionary::class); + } + + return $tbr; + } + + /** + * @return CypherList + */ + public function getAsCypherList(int $key): CypherList + { + $value = $this->get($key); + $tbr = TypeCaster::toCypherList($value); + if ($tbr === null) { + throw new RuntimeTypeException($value, CypherList::class); + } + + return $tbr; + } + + public function getAsDate(int $key): Date + { + return $this->getAsObject($key, Date::class); + } + + public function getAsDateTime(int $key): DateTime + { + return $this->getAsObject($key, DateTime::class); + } + + public function getAsDuration(int $key): Duration + { + return $this->getAsObject($key, Duration::class); + } + + public function getAsLocalDateTime(int $key): LocalDateTime + { + return $this->getAsObject($key, LocalDateTime::class); + } + + public function getAsLocalTime(int $key): LocalTime + { + return $this->getAsObject($key, LocalTime::class); + } + + public function getAsTime(int $key): Time + { + return $this->getAsObject($key, Time::class); + } + + public function getAsNode(int $key): Node + { + return $this->getAsObject($key, Node::class); + } + + public function getAsRelationship(int $key): Relationship + { + return $this->getAsObject($key, Relationship::class); + } + + public function getAsPath(int $key): Path + { + return $this->getAsObject($key, Path::class); + } + + public function getAsCartesian3DPoint(int $key): Cartesian3DPoint + { + return $this->getAsObject($key, Cartesian3DPoint::class); + } + + public function getAsCartesianPoint(int $key): CartesianPoint + { + return $this->getAsObject($key, CartesianPoint::class); + } + + public function getAsWGS84Point(int $key): WGS84Point + { + return $this->getAsObject($key, WGS84Point::class); + } + + public function getAsWGS843DPoint(int $key): WGS843DPoint + { + return $this->getAsObject($key, WGS843DPoint::class); + } + + public function getPackstreamMarker(): int + { + // @see https://neo4j.com/docs/bolt/current/packstream/#data-type-list + $count = $this->count(); + + if ($count <= 0xF) { + return 0x90 + $count; + } + if ($count <= 0xFF) { + return 0xD4; + } + + if ($count <= 0xFF) { + return 0xD5; + } + + return 0xD6; + } +} diff --git a/src/Type/OGM/Date.php b/src/Type/OGM/Date.php new file mode 100644 index 0000000..7eb0d29 --- /dev/null +++ b/src/Type/OGM/Date.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Type\OGM; + +use DateTimeImmutable; +use DateTimeInterface; +use Exception; +use Syndesi\CypherDataStructures\Contract\DateTimeConvertible; +use UnexpectedValueException; + +/** + * A date represented by days since unix epoch. + * + * @psalm-immutable + * + * @extends AbstractPropertyObject + * + * @psalm-suppress TypeDoesNotContainType + */ +final class Date extends AbstractPropertyObject implements DateTimeConvertible +{ + public function __construct(private int $days) + { + } + + /** + * The amount of days since unix epoch. + */ + public function getDays(): int + { + return $this->days; + } + + /** + * Casts to an immutable date time. + * + * @throws Exception + */ + public function toDateTime(): DateTimeImmutable + { + $dateTimeImmutable = (new DateTimeImmutable('@0'))->modify(sprintf('+%s days', $this->days)); + + if ($dateTimeImmutable === false) { + throw new UnexpectedValueException('Expected DateTimeImmutable'); + } + + return $dateTimeImmutable; + } + + public function getProperties(): Dictionary + { + return new Dictionary($this); + } + + public function toArray(): array + { + return ['days' => $this->days]; + } + + public function getPackstreamMarker(): int + { + return 0x44; + } + + public static function fromDateTime(DateTimeInterface $dateTime): DateTimeConvertible + { + return new self((int) $dateTime->diff((new \DateTime('@0')), true)->format('%a')); + } +} diff --git a/src/Type/OGM/DateTime.php b/src/Type/OGM/DateTime.php new file mode 100644 index 0000000..ab7c8c9 --- /dev/null +++ b/src/Type/OGM/DateTime.php @@ -0,0 +1,111 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Type\OGM; + +use DateTimeImmutable; +use DateTimeInterface; +use DateTimeZone; +use Exception; +use RuntimeException; +use function sprintf; + +/** + * A date represented by seconds and nanoseconds since unix epoch, enriched with a timezone offset in seconds. + * + * @psalm-immutable + * + * @extends AbstractPropertyObject + */ +final class DateTime extends AbstractPropertyObject +{ + public function __construct(private int $seconds, private int $nanoseconds, private int $tzOffsetSeconds) + { + } + + /** + * Returns the amount of seconds since unix epoch. + */ + public function getSeconds(): int + { + return $this->seconds; + } + + /** + * Returns the amount of nanoseconds after the seconds have passed. + */ + public function getNanoseconds(): int + { + return $this->nanoseconds; + } + + /** + * Returns the timezone offset in seconds. + */ + public function getTimeZoneOffsetSeconds(): int + { + return $this->tzOffsetSeconds; + } + + /** + * Casts to an immutable date time. + * + * @throws Exception + */ + public function toDateTime(): DateTimeImmutable + { + /** @psalm-suppress all */ + foreach (DateTimeZone::listAbbreviations() as $tz) { + /** @psalm-suppress all */ + if ($tz[0]['offset'] === $this->getTimeZoneOffsetSeconds()) { + return (new DateTimeImmutable(sprintf('@%s', $this->getSeconds()))) + ->modify(sprintf('+%s microseconds', $this->nanoseconds / 1000)) + ->setTimezone(new DateTimeZone($tz[0]['timezone_id'])); + } + } + + $message = sprintf('Cannot find an timezone with %s seconds as offset.', $this->tzOffsetSeconds); + throw new RuntimeException($message); + } + + /** + * @return array{seconds: int, nanoseconds: int, tzOffsetSeconds: int} + */ + public function toArray(): array + { + return [ + 'seconds' => $this->seconds, + 'nanoseconds' => $this->nanoseconds, + 'tzOffsetSeconds' => $this->tzOffsetSeconds, + ]; + } + + public function getProperties(): Dictionary + { + return new Dictionary($this); + } + + public function getPackstreamMarker(): int + { + return 0x49; + } + + public static function fromDateTime(DateTimeInterface $dateTime): self + { + return new self( + $dateTime->getOffset(), + ((int) $dateTime->format('u') * 1000), + $dateTime->getTimezone()->getOffset($dateTime) + ); + } +} diff --git a/src/Type/OGM/DateTimeZoneId.php b/src/Type/OGM/DateTimeZoneId.php new file mode 100644 index 0000000..9d36e30 --- /dev/null +++ b/src/Type/OGM/DateTimeZoneId.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Type\OGM; + +use DateTimeImmutable; +use DateTimeInterface; +use DateTimeZone; +use Exception; +use Syndesi\CypherDataStructures\Contract\DateTimeConvertible; + +use function sprintf; + +/** + * A date represented by seconds and nanoseconds since unix epoch, enriched with a timezone identifier. + * + * @psalm-immutable + * + * @extends AbstractPropertyObject + * + * @psalm-suppress TypeDoesNotContainType + */ +final class DateTimeZoneId extends AbstractPropertyObject implements DateTimeConvertible +{ + public function __construct(private int $seconds, private int $nanoseconds, private string $tzId) + { + } + + /** + * Returns the amount of seconds since unix epoch. + */ + public function getSeconds(): int + { + return $this->seconds; + } + + /** + * Returns the amount of nanoseconds after the seconds have passed. + */ + public function getNanoseconds(): int + { + return $this->nanoseconds; + } + + /** + * Returns the timezone identifier. + */ + public function getTimezoneIdentifier(): string + { + return $this->tzId; + } + + /** + * Casts to an immutable date time. + * + * @throws Exception + */ + public function toDateTime(): DateTimeImmutable + { + $dateTimeImmutable = (new DateTimeImmutable(sprintf('@%s', $this->getSeconds()))) + ->modify(sprintf('+%s microseconds', $this->nanoseconds / 1000)); + + if ($dateTimeImmutable === false) { + throw new \UnexpectedValueException('Expected DateTimeImmutable'); + } + + return $dateTimeImmutable->setTimezone(new DateTimeZone($this->tzId)); + } + + /** + * @return array{seconds: int, nanoseconds: int, tzId: string} + */ + public function toArray(): array + { + return [ + 'seconds' => $this->seconds, + 'nanoseconds' => $this->nanoseconds, + 'tzId' => $this->tzId, + ]; + } + + /** + * @return Dictionary + */ + public function getProperties(): Dictionary + { + return new Dictionary($this); + } + + public function getPackstreamMarker(): int + { + return 0x69; + } + + public static function fromDateTime(DateTimeInterface $dateTime): self + { + return new self( + $dateTime->getOffset(), + ((int) $dateTime->format('u') * 1000), + $dateTime->getTimezone()->getName() + ); + } +} diff --git a/src/Type/OGM/Dictionary.php b/src/Type/OGM/Dictionary.php new file mode 100644 index 0000000..133255e --- /dev/null +++ b/src/Type/OGM/Dictionary.php @@ -0,0 +1,236 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Type\OGM; + +use Syndesi\CypherDataStructures\Contract\PackstreamConvertible; +use Syndesi\CypherDataStructures\Exception\RuntimeTypeException; +use Syndesi\CypherDataStructures\Type\Map; +use Syndesi\CypherDataStructures\Type\Node; +use Syndesi\CypherDataStructures\TypeCaster; + +use function func_num_args; + +/** + * An immutable ordered map of items. + * + * @template TValue + * + * @extends Map + */ +final class Dictionary extends Map implements PackstreamConvertible +{ + /** + * @return Dictionary + */ + public function getAsCypherMap(string $key, mixed $default = null): Dictionary + { + if (func_num_args() === 1) { + $value = $this->get($key); + } else { + /** @var mixed */ + $value = $this->get($key, $default); + } + $tbr = TypeCaster::toCypherMap($value); + if ($tbr === null) { + throw new RuntimeTypeException($value, self::class); + } + + return $tbr; + } + + /** + * @return CypherList + */ + public function getAsCypherList(string $key, mixed $default = null): CypherList + { + if (func_num_args() === 1) { + $value = $this->get($key); + } else { + /** @var mixed */ + $value = $this->get($key, $default); + } + $tbr = TypeCaster::toCypherList($value); + if ($tbr === null) { + throw new RuntimeTypeException($value, CypherList::class); + } + + return $tbr; + } + + public function getAsDate(string $key, mixed $default = null): Date + { + if (func_num_args() === 1) { + return $this->getAsObject($key, Date::class); + } + + return $this->getAsObject($key, Date::class, $default); + } + + public function getAsDateTime(string $key, mixed $default = null): DateTime + { + if (func_num_args() === 1) { + return $this->getAsObject($key, DateTime::class); + } + + return $this->getAsObject($key, DateTime::class, $default); + } + + public function getAsDuration(string $key, mixed $default = null): Duration + { + if (func_num_args() === 1) { + return $this->getAsObject($key, Duration::class); + } + + return $this->getAsObject($key, Duration::class, $default); + } + + public function getAsLocalDateTime(string $key, mixed $default = null): LocalDateTime + { + if (func_num_args() === 1) { + return $this->getAsObject($key, LocalDateTime::class); + } + + return $this->getAsObject($key, LocalDateTime::class, $default); + } + + public function getAsLocalTime(string $key, mixed $default = null): LocalTime + { + if (func_num_args() === 1) { + return $this->getAsObject($key, LocalTime::class); + } + + return $this->getAsObject($key, LocalTime::class, $default); + } + + public function getAsTime(string $key, mixed $default = null): Time + { + if (func_num_args() === 1) { + return $this->getAsObject($key, Time::class); + } + + return $this->getAsObject($key, Time::class, $default); + } + + public function getAsNode(string $key, mixed $default = null): Node + { + if (func_num_args() === 1) { + return $this->getAsObject($key, Node::class); + } + + return $this->getAsObject($key, Node::class, $default); + } + + public function getAsRelationship(string $key, mixed $default = null): Relationship + { + if (func_num_args() === 1) { + return $this->getAsObject($key, Relationship::class); + } + + return $this->getAsObject($key, Relationship::class, $default); + } + + public function getAsPath(string $key, mixed $default = null): Path + { + if (func_num_args() === 1) { + return $this->getAsObject($key, Path::class); + } + + return $this->getAsObject($key, Path::class, $default); + } + + public function getAsCartesian3DPoint(string $key, mixed $default = null): Cartesian3DPoint + { + if (func_num_args() === 1) { + return $this->getAsObject($key, Cartesian3DPoint::class); + } + + return $this->getAsObject($key, Cartesian3DPoint::class, $default); + } + + public function getAsCartesianPoint(string $key, mixed $default = null): CartesianPoint + { + if (func_num_args() === 1) { + return $this->getAsObject($key, CartesianPoint::class); + } + + return $this->getAsObject($key, CartesianPoint::class, $default); + } + + public function getAsWGS84Point(string $key, mixed $default = null): WGS84Point + { + if (func_num_args() === 1) { + return $this->getAsObject($key, WGS84Point::class); + } + + return $this->getAsObject($key, WGS84Point::class, $default); + } + + public function getAsWGS843DPoint(string $key, mixed $default = null): WGS843DPoint + { + if (func_num_args() === 1) { + return $this->getAsObject($key, WGS843DPoint::class); + } + + return $this->getAsObject($key, WGS843DPoint::class, $default); + } + + /** + * @template Value + * + * @param iterable $iterable + * + * @return self + * + * @pure + */ + public static function fromIterable(iterable $iterable): Dictionary + { + return new self($iterable); + } + + /** + * @psalm-mutation-free + */ + public function pluck(string $key): CypherList + { + return CypherList::fromIterable(parent::pluck($key)); + } + + /** + * @psalm-mutation-free + */ + public function keyBy(string $key): Dictionary + { + return Dictionary::fromIterable(parent::keyBy($key)); + } + + public function getPackstreamMarker(): int + { + // @see https://neo4j.com/docs/bolt/current/packstream/#data-type-dictionary + $count = $this->count(); + + if ($count <= 0xF) { + return 0xA0 + $count; + } + if ($count <= 0xFF) { + return 0xD8; + } + + if ($count <= 0xFF) { + return 0xD9; + } + + return 0xDA; + } +} diff --git a/src/Type/OGM/Duration.php b/src/Type/OGM/Duration.php new file mode 100644 index 0000000..b1aee21 --- /dev/null +++ b/src/Type/OGM/Duration.php @@ -0,0 +1,96 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Type\OGM; + +use DateInterval; +use Exception; + +/** + * A temporal range represented in months, days, seconds and nanoseconds. + * + * @psalm-immutable + * + * @extends AbstractPropertyObject + */ +final class Duration extends AbstractPropertyObject +{ + public function __construct(private int $months, private int $days, private int $seconds, private int $nanoseconds) + { + } + + /** + * The amount of months in the duration. + */ + public function getMonths(): int + { + return $this->months; + } + + /** + * The amount of days in the duration after the months have passed. + */ + public function getDays(): int + { + return $this->days; + } + + /** + * The amount of seconds in the duration after the days have passed. + */ + public function getSeconds(): int + { + return $this->seconds; + } + + /** + * The amount of nanoseconds in the duration after the seconds have passed. + */ + public function getNanoseconds(): int + { + return $this->nanoseconds; + } + + /** + * Casts to a DateInterval object. + * + * @throws Exception + */ + public function toDateInterval(): DateInterval + { + return new DateInterval(sprintf('P%dM%dDT%dS', $this->months, $this->days, $this->seconds)); + } + + /** + * @return array{months: int, days: int, seconds: int, nanoseconds: int} + */ + public function toArray(): array + { + return [ + 'months' => $this->months, + 'days' => $this->days, + 'seconds' => $this->seconds, + 'nanoseconds' => $this->nanoseconds, + ]; + } + + public function getProperties(): Dictionary + { + return new Dictionary($this); + } + + public function getPackstreamMarker(): int + { + return 0x45; + } +} diff --git a/src/Type/OGM/LocalDateTime.php b/src/Type/OGM/LocalDateTime.php new file mode 100644 index 0000000..b1073c3 --- /dev/null +++ b/src/Type/OGM/LocalDateTime.php @@ -0,0 +1,96 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Type\OGM; + +use DateTimeImmutable; +use DateTimeInterface; +use Exception; +use Syndesi\CypherDataStructures\Contract\DateTimeConvertible; +use function sprintf; +use UnexpectedValueException; + +/** + * A date time represented in seconds and nanoseconds since the unix epoch. + * + * @psalm-immutable + * + * @extends AbstractPropertyObject + * + * @psalm-suppress TypeDoesNotContainType + */ +final class LocalDateTime extends AbstractPropertyObject implements DateTimeConvertible +{ + public function __construct(private int $seconds, private int $nanoseconds) + { + } + + /** + * The amount of seconds since the unix epoch. + */ + public function getSeconds(): int + { + return $this->seconds; + } + + /** + * The amount of nanoseconds after the seconds have passed. + */ + public function getNanoseconds(): int + { + return $this->nanoseconds; + } + + /** + * @throws Exception + */ + public function toDateTime(): DateTimeImmutable + { + $dateTimeImmutable = (new DateTimeImmutable(sprintf('@%s', $this->getSeconds())))->modify(sprintf('+%s microseconds', $this->nanoseconds / 1000)); + + if ($dateTimeImmutable === false) { + throw new UnexpectedValueException('Expected DateTimeImmutable'); + } + + return $dateTimeImmutable; + } + + /** + * @return array{seconds: int, nanoseconds: int} + */ + public function toArray(): array + { + return [ + 'seconds' => $this->seconds, + 'nanoseconds' => $this->nanoseconds, + ]; + } + + public function getProperties(): Dictionary + { + return new Dictionary($this); + } + + public function getPackstreamMarker(): int + { + return 0x64; + } + + public static function fromDateTime(DateTimeInterface $dateTime): DateTimeConvertible + { + return new self( + $dateTime->getOffset(), + ((int) $dateTime->format('u') * 1000) + ); + } +} diff --git a/src/Type/OGM/Node.php b/src/Type/OGM/Node.php new file mode 100644 index 0000000..3342bf1 --- /dev/null +++ b/src/Type/OGM/Node.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + + +namespace Syndesi\CypherDataStructures\Type\OGM; + +use Syndesi\CypherDataStructures\TypeCaster; +use Syndesi\CypherDataStructures\Exception\PropertyDoesNotExistException; + +use function sprintf; + +/** + * A Node class representing a Node in cypher. + * + * @psalm-import-type OGMTypes from TypeCaster + * + * @psalm-immutable + * + * @extends AbstractPropertyObject> + * @extends AbstractPropertyObject|Dictionary> + */ +final class Node extends AbstractPropertyObject +{ + /** + * @param CypherList $labels + * @param Dictionary $properties + */ + public function __construct(private int $id, private CypherList $labels, private Dictionary $properties) + { + } + + /** + * The labels on the node. + * + * @return CypherList + */ + public function getLabels(): CypherList + { + return $this->labels; + } + + /** + * The id of the node. + */ + public function getId(): int + { + return $this->id; + } + + /** + * Gets the property of the node by key. + * + * @return OGMTypes + */ + public function getProperty(string $key) + { + /** @psalm-suppress ImpureMethodCall */ + if (!$this->properties->hasKey($key)) { + throw new PropertyDoesNotExistException(sprintf('Property "%s" does not exist on node', $key)); + } + + /** @psalm-suppress ImpureMethodCall */ + return $this->properties->get($key); + } + + /** + * @psalm-suppress ImplementedReturnTypeMismatch False positive. + * + * @return array{id: int, labels: CypherList, properties: Dictionary} + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'labels' => $this->labels, + 'properties' => $this->properties, + ]; + } + + public function getProperties(): Dictionary + { + /** @psalm-suppress InvalidReturnStatement false positive with type alias. */ + return $this->properties; + } + + public function getPackstreamMarker(): int + { + return 0x4E; + } +} diff --git a/src/Type/OGM/Path.php b/src/Type/OGM/Path.php new file mode 100644 index 0000000..4bd3ddc --- /dev/null +++ b/src/Type/OGM/Path.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Type\OGM; + +use Syndesi\CypherDataStructures\Type\OGM\Node; + +/** + * A Path class representing a Path in cypher. + * + * @psalm-immutable + * + * @extends AbstractPropertyObject|CypherList|CypherList, CypherList|CypherList|CypherList> + */ +final class Path extends AbstractPropertyObject +{ + /** + * @param CypherList $nodes + * @param CypherList $relationships + * @param CypherList $ids + */ + public function __construct(private CypherList $nodes, private CypherList $relationships, private CypherList $ids) + { + } + + /** + * Returns the node in the path. + * + * @return CypherList + */ + public function getNodes(): CypherList + { + return $this->nodes; + } + + /** + * Returns the relationships in the path. + * + * @return CypherList + */ + public function getRelationships(): CypherList + { + return $this->relationships; + } + + /** + * Returns the ids of the items in the path. + * + * @return CypherList + */ + public function getIds(): CypherList + { + return $this->ids; + } + + /** + * @return array{ids: CypherList, nodes: CypherList, relationships: CypherList} + */ + public function toArray(): array + { + return [ + 'ids' => $this->ids, + 'nodes' => $this->nodes, + 'relationships' => $this->relationships, + ]; + } + + public function getProperties(): Dictionary + { + return new Dictionary($this); + } + + public function getPackstreamMarker(): int + { + return 0x50; + } +} diff --git a/src/Type/OGM/Relationship.php b/src/Type/OGM/Relationship.php new file mode 100644 index 0000000..561b6e3 --- /dev/null +++ b/src/Type/OGM/Relationship.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Type\OGM; + +use Syndesi\CypherDataStructures\TypeCaster; + +/** + * A Relationship class representing a Relationship in cypher. + * + * @psalm-import-type OGMTypes from TypeCaster + * + * @psalm-immutable + */ +final class Relationship extends UnboundRelationship +{ + /** + * @param Dictionary $properties + */ + public function __construct(int $id, private int $startNodeId, private int $endNodeId, string $type, Dictionary $properties) + { + parent::__construct($id, $type, $properties); + } + + /** + * Returns the id of the start node. + */ + public function getStartNodeId(): int + { + return $this->startNodeId; + } + + /** + * Returns the id of the end node. + */ + public function getEndNodeId(): int + { + return $this->endNodeId; + } + + /** + * @psalm-suppress ImplementedReturnTypeMismatch False positive. + * + * @return array{ + * id: int, + * type: string, + * startNodeId: int, + * endNodeId: int, + * properties: Dictionary + * } + */ + public function toArray(): array + { + $tbr = parent::toArray(); + + $tbr['startNodeId'] = $this->getStartNodeId(); + $tbr['endNodeId'] = $this->getEndNodeId(); + + return $tbr; + } + + public function getPackstreamMarker(): int + { + return 0x52; + } +} diff --git a/src/Type/OGM/Time.php b/src/Type/OGM/Time.php new file mode 100644 index 0000000..64f7932 --- /dev/null +++ b/src/Type/OGM/Time.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Type\OGM; + + +/** + * A time object represented in seconds since the unix epoch. + * + * @psalm-immutable + * + * @extends AbstractPropertyObject + */ +final class Time extends AbstractPropertyObject +{ + public function __construct(private int $nanoSeconds, private int $tzOffsetSeconds) + { + } + + /** + * @return array{nanoSeconds: int, tzOffsetSeconds: int} + */ + public function toArray(): array + { + return ['nanoSeconds' => $this->nanoSeconds, 'tzOffsetSeconds' => $this->tzOffsetSeconds]; + } + + public function getTzOffsetSeconds(): int + { + return $this->tzOffsetSeconds; + } + + public function getNanoSeconds(): int + { + return $this->nanoSeconds; + } + + public function getProperties(): Dictionary + { + return new Dictionary($this); + } + + public function getPackstreamMarker(): int + { + return 0x54; + } +} diff --git a/src/Type/OGM/UnboundRelationship.php b/src/Type/OGM/UnboundRelationship.php new file mode 100644 index 0000000..c58fded --- /dev/null +++ b/src/Type/OGM/UnboundRelationship.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Type\OGM; + +use Syndesi\CypherDataStructures\Exception\PropertyDoesNotExistException; +use Syndesi\CypherDataStructures\TypeCaster; + +use function sprintf; + +/** + * A relationship without any nodes attached to it. + * + * @psalm-import-type OGMTypes from TypeCaster + * + * @psalm-immutable + * + * @extends AbstractPropertyObject> + */ +class UnboundRelationship extends AbstractPropertyObject +{ + /** + * @param Dictionary $properties + */ + public function __construct(private int $id, private string $type, private Dictionary $properties) + { + } + + public function getId(): int + { + return $this->id; + } + + public function getType(): string + { + return $this->type; + } + + public function getProperties(): Dictionary + { + /** @psalm-suppress InvalidReturnStatement false positive with type alias. */ + return $this->properties; + } + + /** + * @psalm-suppress ImplementedReturnTypeMismatch False positive. + * + * @return array{id: int, type: string, properties: Dictionary} + */ + public function toArray(): array + { + return [ + 'id' => $this->getId(), + 'type' => $this->getType(), + 'properties' => $this->getProperties(), + ]; + } + + /** + * Gets the property of the relationship by key. + * + * @return OGMTypes + */ + public function getProperty(string $key) + { + /** @psalm-suppress ImpureMethodCall */ + if (!$this->properties->hasKey($key)) { + throw new PropertyDoesNotExistException(sprintf('Property "%s" does not exist on relationship', $key)); + } + + /** @psalm-suppress ImpureMethodCall */ + return $this->properties->get($key); + } + + public function getPackstreamMarker(): int + { + return 0x72; + } +} diff --git a/src/Type/OGM/WGS843DPoint.php b/src/Type/OGM/WGS843DPoint.php new file mode 100644 index 0000000..2ccea56 --- /dev/null +++ b/src/Type/OGM/WGS843DPoint.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Type\OGM; + +use Syndesi\CypherDataStructures\Contract\OGM\PointInterface; + +/** + * A WGS84 Point in three dimensional space. + * + * @see https://neo4j.com/docs/cypher-manual/current/functions/spatial/#functions-point-wgs84-3d + * + * @psalm-immutable + * + * @psalm-import-type Crs from PointInterface + */ +final class WGS843DPoint extends Abstract3DPoint implements PointInterface +{ + public const SRID = 4979; + public const CRS = 'wgs-84-3d'; + + public function getSrid(): int + { + return self::SRID; + } + + public function getLongitude(): float + { + return $this->getX(); + } + + public function getLatitude(): float + { + return $this->getY(); + } + + public function getHeight(): float + { + return $this->getZ(); + } + + public function getCrs(): string + { + return self::CRS; + } +} diff --git a/src/Type/OGM/WGS84Point.php b/src/Type/OGM/WGS84Point.php new file mode 100644 index 0000000..47eb07d --- /dev/null +++ b/src/Type/OGM/WGS84Point.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Type\OGM; + +use Syndesi\CypherDataStructures\Contract\OGM\PointInterface; + +/** + * A WGS84 Point in two dimensional space. + * + * @psalm-immutable + * + * @see https://neo4j.com/docs/cypher-manual/current/functions/spatial/#functions-point-wgs84-2d + * + * @psalm-import-type Crs from PointInterface + */ +final class WGS84Point extends AbstractPoint implements PointInterface +{ + public const SRID = 4326; + public const CRS = 'wgs-84'; + + public function getSrid(): int + { + return self::SRID; + } + + public function getCrs(): string + { + return self::CRS; + } + + /** + * A numeric expression that represents the longitude/x value in decimal degrees. + */ + public function getLongitude(): float + { + return $this->getX(); + } + + /** + * A numeric expression that represents the latitude/y value in decimal degrees. + */ + public function getLatitude(): float + { + return $this->getY(); + } +} diff --git a/src/Type/Pair.php b/src/Type/Pair.php new file mode 100644 index 0000000..a60b8fb --- /dev/null +++ b/src/Type/Pair.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Type; + +/** + * A basic Key value Pair. + * + * @template TKey + * @template TValue + * + * @psalm-immutable + */ +final class Pair +{ + /** + * @param TKey $key + * @param TValue $value + */ + public function __construct(private $key, private $value) + { + } + + /** + * @return TKey + */ + public function getKey() + { + return $this->key; + } + + /** + * @return TValue + */ + public function getValue() + { + return $this->value; + } +} diff --git a/src/TypeCaster.php b/src/TypeCaster.php new file mode 100644 index 0000000..46d6430 --- /dev/null +++ b/src/TypeCaster.php @@ -0,0 +1,169 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures; + +use Syndesi\CypherDataStructures\Type\Node; +use Syndesi\CypherDataStructures\Type\OGM\Cartesian3DPoint; +use Syndesi\CypherDataStructures\Type\OGM\CartesianPoint; +use Syndesi\CypherDataStructures\Type\OGM\CypherList; +use Syndesi\CypherDataStructures\Type\OGM\Date; +use Syndesi\CypherDataStructures\Type\OGM\DateTime; +use Syndesi\CypherDataStructures\Type\OGM\Dictionary; +use Syndesi\CypherDataStructures\Type\OGM\Duration; +use Syndesi\CypherDataStructures\Type\OGM\LocalDateTime; +use Syndesi\CypherDataStructures\Type\OGM\LocalTime; +use Syndesi\CypherDataStructures\Type\OGM\Path; +use Syndesi\CypherDataStructures\Type\OGM\Relationship; +use Syndesi\CypherDataStructures\Type\OGM\Time; +use Syndesi\CypherDataStructures\Type\OGM\WGS843DPoint; +use Syndesi\CypherDataStructures\Type\OGM\WGS84Point; + +use function is_a; +use function is_iterable; +use function is_numeric; +use function is_object; +use function is_scalar; +use function method_exists; + +/** + * @psalm-type OGMTypes = string|int|float|bool|null|Date|DateTime|Duration|LocalDateTime|LocalTime|Time|CypherList|Dictionary|Node|Relationship|Path|Cartesian3DPoint|CartesianPoint|WGS84Point|WGS843DPoint + */ +final class TypeCaster +{ + /** + * @pure + */ + public static function toString(mixed $value): ?string + { + if ($value === null || is_scalar($value) || (is_object($value) && method_exists($value, '__toString'))) { + return (string) $value; + } + + return null; + } + + /** + * @pure + */ + public static function toFloat(mixed $value): ?float + { + $value = self::toString($value); + if (is_numeric($value)) { + return (float) $value; + } + + return null; + } + + /** + * @pure + */ + public static function toInt(mixed $value): ?int + { + $value = self::toFloat($value); + if ($value !== null) { + return (int) $value; + } + + return null; + } + + /** + * @return null + * + * @pure + */ + public static function toNull() + { + return null; + } + + /** + * @pure + */ + public static function toBool(mixed $value): ?bool + { + $value = self::toInt($value); + if ($value !== null) { + return (bool) $value; + } + + return null; + } + + /** + * @template T + * + * @param class-string $class + * + * @return T|null + * @pure + */ + public static function toClass(mixed $value, string $class): ?object + { + if (is_a($value, $class)) { + /** @var T */ + return $value; + } + + return null; + } + + /** + * + * @return list + * @psalm-external-mutation-free + */ + public static function toArray(mixed $value): ?array + { + if (is_iterable($value)) { + $tbr = []; + /** @var mixed $x */ + foreach ($value as $x) { + /** @var mixed */ + $tbr[] = $x; + } + + return $tbr; + } + + return null; + } + + /** + * + * @return CypherList|null + * @pure + */ + public static function toCypherList(mixed $value): ?CypherList + { + if (is_iterable($value)) { + return CypherList::fromIterable($value); + } + + return null; + } + + /** + * @return Dictionary|null + */ + public static function toCypherMap(mixed $value): ?Dictionary + { + if (is_iterable($value)) { + return Dictionary::fromIterable($value); + } + + return null; + } +} diff --git a/tests/Type/CypherListTest.php b/tests/Type/CypherListTest.php new file mode 100644 index 0000000..5047cdd --- /dev/null +++ b/tests/Type/CypherListTest.php @@ -0,0 +1,472 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Tests\Type; + +use function array_sum; +use ArrayIterator; +use BadMethodCallException; +use Generator; +use function hexdec; +use function json_encode; +use Syndesi\CypherDataStructures\Type\Pair; +use Syndesi\CypherDataStructures\Type\OGM\CypherList; +use OutOfBoundsException; +use PHPUnit\Framework\TestCase; +use function range; +use stdClass; + +/** + * @psalm-suppress MixedOperand + * @psalm-suppress MixedAssignment + */ +final class CypherListTest extends TestCase +{ + /** @var CypherList */ + private CypherList $list; + + public function setUp(): void + { + parent::setUp(); + + $this->list = new CypherList(['A', 'B', 'C']); + } + + public function testFromIterableEqual(): void + { + $fromIterable = CypherList::fromIterable($this->list); + + self::assertNotSame($this->list, $fromIterable); + self::assertEquals($this->list->toArray(), $fromIterable->toArray()); + } + + public function testFromIterableArray(): void + { + $fromIterable = CypherList::fromIterable(['A', 'B', 'C']); + + self::assertNotSame($this->list, $fromIterable); + self::assertEquals($this->list, $fromIterable); + } + + public function testFromIterable(): void + { + $fromIterable = CypherList::fromIterable(new ArrayIterator(['A', 'B', 'C'])); + + self::assertNotSame($this->list, $fromIterable); + self::assertEquals($this->list->toArray(), $fromIterable->toArray()); + } + + public function testCount(): void + { + self::assertCount(3, $this->list); + } + + public function testCountEmpty(): void + { + self::assertCount(0, new CypherList()); + } + + public function testCopy(): void + { + $copy = $this->list->copy(); + + self::assertNotSame($this->list, $copy); + self::assertEquals($this->list->toArray(), $copy->toArray()); + } + + public function testCopyDepth(): void + { + $list = new CypherList([new stdClass()]); + $copy = $list->copy(); + + self::assertNotSame($list, $copy); + self::assertEquals($list->toArray(), $copy->toArray()); + self::assertSame($list[0], $copy[0]); + } + + public function testIsEmpty(): void + { + self::assertFalse($this->list->isEmpty()); + } + + public function testIsEmptyEmpty(): void + { + self::assertTrue((new CypherList())->isEmpty()); + } + + public function testToArray(): void + { + self::assertEquals(['A', 'B', 'C'], $this->list->toArray()); + } + + public function testMerge(): void + { + self::assertEquals((new CypherList(['A', 'B', 'C', 'A', 'B', 'C']))->toArray(), $this->list->merge($this->list)->toArray()); + } + + public function testHasKey(): void + { + self::assertFalse($this->list->hasKey(-1)); + self::assertTrue($this->list->hasKey(0)); + self::assertTrue($this->list->hasKey(1)); + self::assertTrue($this->list->hasKey(2)); + self::assertFalse($this->list->hasKey(3)); + } + + public function testFilterPermissive(): void + { + $filter = $this->list->filter(static fn () => true)->toArray(); + + self::assertEquals($this->list->toArray(), $filter); + self::assertNotSame($this->list, $filter); + } + + public function testFilterBlock(): void + { + $filter = $this->list->filter(static fn () => false)->toArray(); + + self::assertEquals([], $filter); + } + + public function testFilterSelective(): void + { + $filter = $this->list->filter(static fn (string $x, int $i) => $x === 'B' || $i === 2)->toArray(); + + self::assertEquals(['B', 'C'], $filter); + } + + public function testMap(): void + { + $filter = $this->list->map(static fn (string $x, int $i) => $i.':'.$x)->toArray(); + + self::assertEquals(['0:A', '1:B', '2:C'], $filter); + } + + public function testReduce(): void + { + $count = $this->list->reduce(static function (?int $initial, string $value, int $key) { + return ($initial ?? 0) + $key * hexdec($value); + }, 5); + + self::assertEquals(5 + hexdec('B') + 2 * hexdec('C'), $count); + } + + public function testFind(): void + { + self::assertFalse($this->list->find('X')); + self::assertEquals(0, $this->list->find('A')); + self::assertEquals(1, $this->list->find('B')); + self::assertEquals(2, $this->list->find('C')); + } + + public function testReversed(): void + { + self::assertEquals(['C', 'B', 'A'], $this->list->reversed()->toArray()); + self::assertEquals(['A', 'B', 'C'], $this->list->toArray()); + self::assertEquals(['A', 'B', 'C'], $this->list->reversed()->reversed()->toArray()); + } + + public function testSliceSingle(): void + { + $sliced = $this->list->slice(1, 1); + self::assertEquals(['B'], $sliced->toArray()); + } + + public function testSliceDouble(): void + { + $sliced = $this->list->slice(1, 2); + self::assertEquals(['B', 'C'], $sliced->toArray()); + } + + public function testSliceAll(): void + { + $sliced = $this->list->slice(0, 3); + self::assertEquals(['A', 'B', 'C'], $sliced->toArray()); + } + + public function testSliceTooMuch(): void + { + $sliced = $this->list->slice(0, 5); + self::assertEquals(['A', 'B', 'C'], $sliced->toArray()); + } + + public function testSliceEmpty(): void + { + $sliced = $this->list->slice(0, 0); + self::assertEquals([], $sliced->toArray()); + } + + public function testGetValid(): void + { + self::assertEquals('A', $this->list->get(0)); + self::assertEquals('B', $this->list->get(1)); + self::assertEquals('C', $this->list->get(2)); + } + + public function testFirst(): void + { + self::assertEquals('A', $this->list->first()); + } + + public function testFirstInvalid(): void + { + $this->expectException(OutOfBoundsException::class); + $this->expectExceptionMessage('Cannot grab first element of an empty list'); + (new CypherList())->first(); + } + + public function testLast(): void + { + self::assertEquals('C', $this->list->last()); + } + + public function testLastInvalid(): void + { + $this->expectException(OutOfBoundsException::class); + $this->expectExceptionMessage('Cannot grab last element of an empty list'); + (new CypherList())->last(); + } + + public function testGetInvalid(): void + { + $this->expectException(OutOfBoundsException::class); + $this->expectExceptionMessage('Offset: "3" does not exists in object of instance: Laudis\Neo4j\Types\CypherList'); + $this->list->get(3); + } + + public function testGetNegative(): void + { + $this->expectException(OutOfBoundsException::class); + $this->expectExceptionMessage('Offset: "-1" does not exists in object of instance: Laudis\Neo4j\Types\CypherList'); + $this->list->get(-1); + } + + public function testIteration(): void + { + $counter = 0; + foreach ($this->list as $key => $item) { + ++$counter; + self::assertEquals(['A', 'B', 'C'][$key], $item); + } + self::assertEquals(3, $counter); + } + + public function testIterationEmpty(): void + { + $counter = 0; + foreach ((new CypherList()) as $key => $item) { + ++$counter; + self::assertEquals(['A', 'B', 'C'][$key], $item); + } + self::assertEquals(0, $counter); + } + + public function testOffsetSet(): void + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Laudis\Neo4j\Types\CypherList is immutable'); + + $this->list[0] = 'a'; + } + + public function testOffsetUnset(): void + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Laudis\Neo4j\Types\CypherList is immutable'); + + unset($this->list[0]); + } + + public function testOffsetGetValid(): void + { + self::assertEquals('A', $this->list[0]); + self::assertEquals('B', $this->list[1]); + self::assertEquals('C', $this->list[2]); + } + + public function testOffsetGetInvalid(): void + { + $this->expectException(OutOfBoundsException::class); + $this->expectExceptionMessage('Offset: "3" does not exists in object of instance: Laudis\Neo4j\Types\CypherList'); + $this->list[3]; + } + + public function testOffsetGetNegative(): void + { + $this->expectException(OutOfBoundsException::class); + $this->expectExceptionMessage('Offset: "-1" does not exists in object of instance: Laudis\Neo4j\Types\CypherList'); + $this->list[-1]; + } + + public function testIssetValid(): void + { + self::assertTrue(isset($this->list[0])); + self::assertTrue(isset($this->list[1])); + self::assertTrue(isset($this->list[2])); + } + + public function testIssetInValid(): void + { + self::assertFalse(isset($this->list[-1])); + self::assertFalse(isset($this->list[3])); + } + + public function testIssetValidNull(): void + { + self::assertTrue(isset((new CypherList([null]))[0])); + } + + public function testJsonSerialize(): void + { + self::assertEquals('["A","B","C"]', json_encode($this->list, JSON_THROW_ON_ERROR)); + } + + public function testJsonSerializeEmpty(): void + { + self::assertEquals('[]', json_encode(new CypherList(), JSON_THROW_ON_ERROR)); + } + + public function testJoin(): void + { + self::assertEquals('A;B;C', $this->list->join(';')); + } + + public function testJoinEmpty(): void + { + self::assertEquals('', (new CypherList())->join('A')); + } + + public function testSortedDefault(): void + { + self::assertEquals($this->list->toArray(), $this->list->sorted()->toArray()); + self::assertEquals($this->list->toArray(), $this->list->reversed()->sorted()->toArray()); + } + + public function testSortedCustom(): void + { + $sorted = $this->list->sorted(static fn (string $x, string $y): int => -1 * ($x <=> $y)); + + self::assertEquals(['C', 'B', 'A'], $sorted->toArray()); + self::assertEquals(['A', 'B', 'C'], $this->list->toArray()); + } + + public function testEach(): void + { + $cntr = -1; + /** @psalm-suppress UnusedClosureParam */ + $this->list->each(static function (string $x, int $key) use (&$cntr) { $cntr = $key; }); + + self::assertEquals($this->list->count() - 1, $cntr); + } + + public function testMapTypings(): void + { + $map = CypherList::fromIterable(['a', 'b', 'c']) + ->map(static function (string $value, int $key): stdClass { + $tbr = new stdClass(); + + $tbr->key = $key; + $tbr->value = $value; + + return $tbr; + }) + ->map(static function (stdClass $class) { + return (string) $class->value; + }) + ->toArray(); + + self::assertEquals(['a', 'b', 'c'], $map); + } + + public function testKeyBy(): void + { + $object = new stdClass(); + $object->x = 'stdClassX'; + $object->y = 'wrong'; + $list = CypherList::fromIterable([ + 1, + $object, + ['x' => 'arrayX', 'y' => 'wrong'], + 'wrong', + ])->pluck('x'); + + self::assertEquals(['stdClassX', 'arrayX'], $list->toArray()); + } + + public function testCombined(): void + { + $i = 0; + $list = CypherList::fromIterable([0, 1, 2, 3])->map(static function ($x) use (&$i) { + ++$i; + + return $x; + }); + + /** @var int $i */ + self::assertEquals(0, $i); + + $pairs = $list->map(static fn ($x, $index): Pair => new Pair($index, $x)); + self::assertEquals(0, $i); + + self::assertCount(4, $pairs); + self::assertEquals(4, $i); + + self::assertCount(4, $list); + self::assertEquals(4, $i); + } + + public function testSlice(): void + { + $sumBefore = 0; + $sumAfter = 0; + $range = CypherList::fromIterable($this->infiniteIterator()) + ->map(static function ($x) use (&$sumBefore) { + $sumBefore += $x; + + return $x; + }) + ->slice(5, 3) + ->map(static function ($x) use (&$sumAfter) { + $sumAfter += $x; + + return $x; + }); + + /** @var int $sumBefore */ + /** @var int $sumAfter */ + $start = $range->get(0); + + self::assertEquals(5, $start); + + self::assertEquals(array_sum(range(0, 5)), $sumBefore); + self::assertEquals(5, $sumAfter); + + $end = $range->get(2); + + self::assertEquals(7, $end); + self::assertEquals(array_sum(range(0, 7)), $sumBefore); + self::assertEquals(array_sum(range(5, 7)), $sumAfter); + } + + /** + * @return Generator + */ + private function infiniteIterator(): Generator + { + $i = 0; + while (true) { + yield $i; + ++$i; + } + } +} diff --git a/tests/Type/CypherTypeTest.php b/tests/Type/CypherTypeTest.php new file mode 100644 index 0000000..48eed4c --- /dev/null +++ b/tests/Type/CypherTypeTest.php @@ -0,0 +1,142 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Tests\Type; + +use BadMethodCallException; +use function json_encode; +use JsonException; +use Syndesi\CypherDataStructures\Type\OGM\AbstractCypherObject; +use OutOfBoundsException; +use PHPUnit\Framework\TestCase; + +/** + * @extends AbstractCypherObject + * @psalm-immutable + */ +final class BogusCypherObject extends AbstractCypherObject +{ + public function toArray(): array + { + return []; + } + + public function getPackstreamMarker(): int + { + return 0x0; + } +} + +/** + * @extends AbstractCypherObject + * @psalm-immutable + */ +final class BogusCypherObjectFilled extends AbstractCypherObject +{ + public function toArray(): array + { + return [ + 'a' => 'b', + 'c' => 'd', + ]; + } + + public function getPackstreamMarker(): int + { + return 0x0; + } +} + +final class CypherTypeTest extends TestCase +{ + /** + * @throws JsonException + * + * @psalm-suppress all + */ + public function testEmpty(): void + { + $empty = new BogusCypherObject(); + + self::assertEquals('[]', json_encode($empty, JSON_THROW_ON_ERROR)); + self::assertFalse(isset($empty[0])); + self::assertNull($empty[0] ?? null); + + $caught = null; + try { + $empty[0] = 'abc'; + } catch (BadMethodCallException $e) { + $caught = true; + } + self::assertTrue($caught, 'Empty is writable'); + + $caught = null; + try { + unset($empty[0]); + } catch (BadMethodCallException $e) { + $caught = true; + } + self::assertTrue($caught, 'Empty is writable'); + + $caught = null; + try { + $empty[0]; + } catch (OutOfBoundsException $e) { + $caught = true; + } + self::assertTrue($caught, 'Empty has still valid access'); + } + + /** + * @throws JsonException + * + * @psalm-suppress all + */ + public function testFilled(): void + { + $filled = new BogusCypherObjectFilled(); + + self::assertEquals('{"a":"b","c":"d"}', json_encode($filled, JSON_THROW_ON_ERROR)); + + self::assertFalse(isset($filled[0])); + self::assertNull($filled[0] ?? null); + + self::assertTrue(isset($filled['a'])); + self::assertTrue(isset($filled['c'])); + self::assertEquals('b', $filled['a']); + self::assertEquals('d', $filled['c']); + + $caught = null; + try { + $filled[0] = 'abc'; + } catch (BadMethodCallException $e) { + $caught = true; + } + self::assertTrue($caught, 'Filled is writable'); + $caught = null; + try { + unset($filled[0]); + } catch (BadMethodCallException $e) { + $caught = true; + } + self::assertTrue($caught, 'Filled is writable'); + + $caught = null; + try { + $filled[0]; + } catch (OutOfBoundsException $e) { + $caught = true; + } + self::assertTrue($caught, 'Filled has still valid access'); + } +} diff --git a/tests/Type/DictionaryTest.php b/tests/Type/DictionaryTest.php new file mode 100644 index 0000000..708e329 --- /dev/null +++ b/tests/Type/DictionaryTest.php @@ -0,0 +1,500 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Syndesi\CypherDataStructures\Tests\Type; + +use ArrayIterator; +use BadMethodCallException; +use Generator; +use IteratorAggregate; +use function json_encode; +use const JSON_THROW_ON_ERROR; +use Syndesi\CypherDataStructures\Type\Pair; +use Syndesi\CypherDataStructures\Exception\RuntimeTypeException; +use Syndesi\CypherDataStructures\Type\ArrayList; +use Syndesi\CypherDataStructures\Type\OGM\Dictionary; +use OutOfBoundsException; +use PHPUnit\Framework\TestCase; +use stdClass; + +final class DictionaryTest extends TestCase +{ + /** @var Dictionary */ + private Dictionary $map; + + public function setUp(): void + { + parent::setUp(); + + $this->map = new Dictionary(['A' => 'x', 'B' => 'y', 'C' => 'z']); + } + + public function testFromIterableEqual(): void + { + $fromIterable = Dictionary::fromIterable($this->map); + + self::assertNotSame($this->map, $fromIterable); + self::assertEquals($this->map->toArray(), $fromIterable->toArray()); + } + + public function testFromIterableArray(): void + { + $fromIterable = Dictionary::fromIterable(['A' => 'x', 'B' => 'y', 'C' => 'z']); + + self::assertNotSame($this->map, $fromIterable); + self::assertEquals($this->map->toArray(), $fromIterable->toArray()); + } + + public function testFromIterable(): void + { + $fromIterable = Dictionary::fromIterable(new ArrayIterator(['A' => 'x', 'B' => 'y', 'C' => 'z'])); + + self::assertNotSame($this->map, $fromIterable); + self::assertEquals($this->map->toArray(), $fromIterable->toArray()); + } + + public function testCount(): void + { + self::assertCount(3, $this->map); + } + + public function testCountEmpty(): void + { + self::assertCount(0, new Dictionary()); + } + + public function testCopy(): void + { + $copy = $this->map->copy(); + + self::assertNotSame($this->map, $copy); + self::assertEquals($this->map->toArray(), $copy->toArray()); + } + + public function testCopyDepth(): void + { + $list = new Dictionary(['A' => new stdClass()]); + $copy = $list->copy(); + + self::assertNotSame($list, $copy); + self::assertEquals($list->toArray(), $copy->toArray()); + self::assertSame($list['A'], $copy['A']); + } + + public function testIsEmpty(): void + { + self::assertFalse($this->map->isEmpty()); + } + + public function testIsEmptyEmpty(): void + { + self::assertTrue((new Dictionary())->isEmpty()); + } + + public function testToArray(): void + { + self::assertEquals(['A' => 'x', 'B' => 'y', 'C' => 'z'], $this->map->toArray()); + } + + public function testMerge(): void + { + self::assertEquals(['A' => 'x', 'B' => 'y', 'C' => 'z'], $this->map->merge($this->map)->toArray()); + } + + public function testMergeDifferent(): void + { + $merged = $this->map->merge(['B' => 'yy', 'C' => 'z', 'D' => 'e']); + self::assertEquals(['A' => 'x', 'B' => 'yy', 'C' => 'z', 'D' => 'e'], $merged->toArray()); + } + + public function testHasKey(): void + { + self::assertTrue($this->map->hasKey('A')); + self::assertTrue($this->map->hasKey('B')); + self::assertTrue($this->map->hasKey('C')); + self::assertFalse($this->map->hasKey('E')); + self::assertFalse($this->map->hasKey('a')); + } + + public function testFilterPermissive(): void + { + $filter = $this->map->filter(static fn () => true); + + self::assertEquals($this->map->toArray(), $filter->toArray()); + self::assertNotSame($this->map, $filter); + } + + public function testFilterBlock(): void + { + $filter = $this->map->filter(static fn () => false); + + self::assertEquals([], $filter->toArray()); + } + + public function testFilterSelective(): void + { + $filter = $this->map->filter(static fn (string $x, string $i) => !($i === 'B' || $x === 'z')); + + self::assertEquals(['A' => 'x'], $filter->toArray()); + } + + public function testMap(): void + { + $filter = $this->map->map(static fn (string $x, string $i) => $i.':'.$x); + + self::assertEquals(['A' => 'A:x', 'B' => 'B:y', 'C' => 'C:z'], $filter->toArray()); + } + + public function testReduce(): void + { + $count = $this->map->reduce(static function (?int $initial, string $key, string $value) { + return ($initial ?? 0) + ord($value) + ord($key); + }, 5); + + self::assertEquals(5 + ord('A') + ord('x') + ord('B') + ord('y') + ord('C') + ord('z'), $count); + } + + public function testFind(): void + { + self::assertFalse($this->map->find('A')); + self::assertFalse($this->map->find('X')); + self::assertEquals('C', $this->map->find('z')); + self::assertEquals('B', $this->map->find('y')); + self::assertEquals('A', $this->map->find('x')); + } + + public function testReversed(): void + { + self::assertEquals(['C' => 'z', 'B' => 'y', 'A' => 'x'], $this->map->reversed()->toArray()); + self::assertEquals(['A' => 'x', 'B' => 'y', 'C' => 'z'], $this->map->toArray()); + self::assertEquals(['A' => 'x', 'B' => 'y', 'C' => 'z'], $this->map->reversed()->reversed()->toArray()); + } + + public function testSliceSingle(): void + { + $sliced = $this->map->slice(1, 1); + self::assertEquals(['B' => 'y'], $sliced->toArray()); + } + + public function testSliceDouble(): void + { + $sliced = $this->map->slice(1, 2); + self::assertEquals(['B' => 'y', 'C' => 'z'], $sliced->toArray()); + } + + public function testSliceAll(): void + { + $sliced = $this->map->slice(0, 3)->toArray(); + self::assertEquals($this->map->toArray(), $sliced); + } + + public function testSliceTooMuch(): void + { + $sliced = $this->map->slice(0, 5); + self::assertEquals($this->map->toArray(), $sliced->toArray()); + } + + public function testSliceEmpty(): void + { + $sliced = $this->map->slice(0, 0); + self::assertEquals([], $sliced->toArray()); + } + + public function testGetValid(): void + { + self::assertEquals('x', $this->map->get('A')); + self::assertEquals('y', $this->map->get('B')); + self::assertEquals('z', $this->map->get('C')); + } + + public function testGetDefault(): void + { + self::assertEquals('x', $this->map->get('A', null)); + self::assertNull($this->map->get('x', null)); + self::assertEquals(new stdClass(), $this->map->get('Cd', new stdClass())); + } + + public function testFirst(): void + { + self::assertEquals(new Pair('A', 'x'), $this->map->first()); + } + + public function testFirstInvalid(): void + { + $this->expectException(OutOfBoundsException::class); + $this->expectExceptionMessage('Cannot grab first element of an empty map'); + (new Dictionary())->first(); + } + + public function testLast(): void + { + self::assertEquals(new Pair('C', 'z'), $this->map->last()); + } + + public function testLastInvalid(): void + { + $this->expectException(OutOfBoundsException::class); + $this->expectExceptionMessage('Cannot grab last element of an empty map'); + (new Dictionary())->last(); + } + + public function testGetInvalid(): void + { + $this->expectException(OutOfBoundsException::class); + $this->expectExceptionMessage('Cannot get item in sequence with key: a'); + $this->map->get('a'); + } + + public function testIteration(): void + { + $counter = 0; + foreach ($this->map as $key => $item) { + ++$counter; + self::assertEquals(['A' => 'x', 'B' => 'y', 'C' => 'z'][$key], $item); + } + self::assertEquals(3, $counter); + } + + public function testIterationEmpty(): void + { + $counter = 0; + foreach ((new Dictionary()) as $key => $item) { + ++$counter; + self::assertEquals(['A' => 'x'][$key], $item); + } + self::assertEquals(0, $counter); + } + + public function testOffsetSet(): void + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Laudis\Neo4j\Types\Dictionary is immutable'); + + $this->map['A'] = 'a'; + } + + public function testOffsetUnset(): void + { + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Laudis\Neo4j\Types\Dictionary is immutable'); + + unset($this->map['A']); + } + + public function testOffsetGetValid(): void + { + self::assertEquals('x', $this->map['A']); + self::assertEquals('y', $this->map['B']); + self::assertEquals('z', $this->map['C']); + } + + public function testOffsetGetInvalid(): void + { + $this->expectException(OutOfBoundsException::class); + $this->expectExceptionMessage('Offset: "AA" does not exists in object of instance: Laudis\Neo4j\Types\Dictionary'); + $this->map['AA']; + } + + public function testIssetValid(): void + { + self::assertTrue(isset($this->map['A'])); + self::assertTrue(isset($this->map['B'])); + self::assertTrue(isset($this->map['C'])); + } + + public function testIssetInValid(): void + { + self::assertFalse(isset($this->map['a'])); + } + + public function testIssetValidNull(): void + { + self::assertTrue(isset((new Dictionary(['a' => null]))['a'])); + } + + public function testJsonSerialize(): void + { + self::assertEquals('{"A":"x","B":"y","C":"z"}', json_encode($this->map, JSON_THROW_ON_ERROR)); + } + + public function testJsonSerializeEmpty(): void + { + self::assertEquals('{}', json_encode(new Dictionary(), JSON_THROW_ON_ERROR)); + } + + public function testJoin(): void + { + self::assertEquals('x;y;z', $this->map->join(';')); + } + + public function testJoinEmpty(): void + { + self::assertEquals('', (new Dictionary())->join('A')); + } + + public function testDiff(): void + { + $subtract = new Dictionary(['B' => 'x', 'Z' => 'z']); + $result = $this->map->diff($subtract); + + self::assertEquals(['A' => 'x', 'C' => 'z'], $result->toArray()); + self::assertEquals(['B' => 'x', 'Z' => 'z'], $subtract->toArray()); + self::assertEquals(['A' => 'x', 'B' => 'y', 'C' => 'z'], $this->map->toArray()); + } + + public function testDiffEmpty(): void + { + self::assertEquals(['A' => 'x', 'B' => 'y', 'C' => 'z'], $this->map->diff([])->toArray()); + } + + public function testIntersect(): void + { + $intersect = new Dictionary(['B' => 'x', 'Z' => 'z']); + $result = $this->map->intersect($intersect); + + self::assertEquals(['B' => 'y'], $result->toArray()); + self::assertEquals(['B' => 'x', 'Z' => 'z'], $intersect->toArray()); + self::assertEquals(['A' => 'x', 'B' => 'y', 'C' => 'z'], $this->map->toArray()); + } + + public function testUnion(): void + { + $intersect = new Dictionary(['B' => 'x', 'Z' => 'z']); + $result = $this->map->union($intersect); + + self::assertEquals(['A' => 'x', 'B' => 'y', 'C' => 'z', 'Z' => 'z'], $result->toArray()); + self::assertEquals(['B' => 'x', 'Z' => 'z'], $intersect->toArray()); + self::assertEquals(['A' => 'x', 'B' => 'y', 'C' => 'z'], $this->map->toArray()); + } + + public function testXor(): void + { + $intersect = new Dictionary(['B' => 'x', 'Z' => 'z']); + $result = $this->map->xor($intersect); + + self::assertEquals(['A' => 'x', 'C' => 'z', 'Z' => 'z'], $result->toArray()); + self::assertEquals(['B' => 'x', 'Z' => 'z'], $intersect->toArray()); + self::assertEquals(['A' => 'x', 'B' => 'y', 'C' => 'z'], $this->map->toArray()); + } + + public function testValue(): void + { + self::assertEquals(['x', 'y', 'z'], $this->map->values()->toArray()); + } + + public function testKeys(): void + { + self::assertEquals(['A', 'B', 'C'], $this->map->keys()->toArray()); + } + + public function testPairs(): void + { + $list = new ArrayList([new Pair('A', 'x'), new Pair('B', 'y'), new Pair('C', 'z')]); + self::assertEquals($list->toArray(), $this->map->pairs()->toArray()); + } + + public function testSkip(): void + { + self::assertEquals(new Pair('A', 'x'), $this->map->skip(0)); + self::assertEquals(new Pair('B', 'y'), $this->map->skip(1)); + self::assertEquals(new Pair('C', 'z'), $this->map->skip(2)); + } + + public function testSkipInvalid(): void + { + $this->expectException(OutOfBoundsException::class); + $this->expectExceptionMessage('Cannot skip to a pair at position: 4'); + self::assertEquals(new Pair('A', 'x'), $this->map->skip(4)); + } + + public function testInvalidConstruct(): void + { + $map = new Dictionary(new class() implements IteratorAggregate { + public function getIterator(): Generator + { + yield new stdClass() => 'x'; + } + }); + + self::assertEquals(Dictionary::fromIterable(['0' => 'x'])->toArray(), $map->toArray()); + } + + public function testSortedDefault(): void + { + self::assertEquals($this->map->toArray(), $this->map->sorted()->toArray()); + self::assertEquals($this->map->toArray(), $this->map->reversed()->sorted()->toArray()); + } + + public function testSortedCustom(): void + { + $sorted = $this->map->sorted(static fn (string $x, string $y): int => -1 * ($x <=> $y)); + + self::assertEquals(['C' => 'z', 'B' => 'y', 'A' => 'x'], $sorted->toArray()); + self::assertEquals(['A' => 'x', 'B' => 'y', 'C' => 'z'], $this->map->toArray()); + } + + public function testKSorted(): void + { + self::assertEquals($this->map->toArray(), $this->map->ksorted()->toArray()); + self::assertEquals($this->map->toArray(), $this->map->reversed()->ksorted()->toArray()); + } + + public function testKSortedCustom(): void + { + $sorted = $this->map->ksorted(static fn (string $x, string $y) => -1 * ($x <=> $y)); + + self::assertEquals(['C' => 'z', 'B' => 'y', 'A' => 'x'], $sorted->toArray()); + self::assertEquals(['A' => 'x', 'B' => 'y', 'C' => 'z'], $this->map->toArray()); + } + + public function testCasts(): void + { + $map = new Dictionary(['a' => null]); + + self::assertEquals('', $map->getAsString('a')); + + $this->expectException(RuntimeTypeException::class); + $map->getAsCartesian3DPoint('a'); + } + + public function getMap(): void + { + $map = Dictionary::fromIterable(['a' => 'b', 'c' => 'd']) + ->map(static function (string $value, string $key) { + $tbr = new stdClass(); + + $tbr->key = $key; + $tbr->value = $value; + + return $tbr; + }) + ->map(static function (stdClass $class) { + return (string) $class->value; + }); + + self::assertEquals(Dictionary::fromIterable(['a' => 'b', 'c' => 'd']), $map); + } + + public function testKeyBy(): void + { + $object = new stdClass(); + $object->x = 'stdClassX'; + $object->y = 'wrong'; + $list = Dictionary::fromIterable([ + 'w' => 1, + 'x' => $object, + 'y' => ['x' => 'arrayX', 'y' => 'wrong'], + 'z' => 'wrong', + ])->pluck('x'); + + self::assertEquals(['stdClassX', 'arrayX'], $list->toArray()); + } +}