diff --git a/docs/immutability.md b/docs/immutability.md index 7adb197..99b7135 100644 --- a/docs/immutability.md +++ b/docs/immutability.md @@ -246,3 +246,22 @@ Their declared-context setters accept a second `$propagate` argument, defaulting to `true`. Propagation edits all contained points in place, so other paths sharing those points observe the updated point metadata. Passing `false` updates only the receiving path. Ordinates never change during a declared-context update. + +### Mutable aggregate ownership + +Mutable multi-points, multi-line strings, multi-polygons and `SpatialCollection` +retain supplied members and descendants by reference. Their common +`getElement()`/`getElements()` methods expose those same instances. +`setElement()` replaces one membership entry; editing a retained object is +visible through every aggregate referencing it. Complete `setElements()` +replacement checks all inputs before changing membership. + +These aggregates reject immutable spatial components recursively. Containment +cycles are also rejected atomically; repeated members and shared descendants +remain supported when their references form an acyclic graph. Coordinate +arrays supported by the multi-object constructors and `setElements()` create +mutable descendants. The three context setters propagate through all members +by default; `false` changes only the receiver. Propagation modifies shared +instances without recalculating coordinates. See the +[aggregate API reference](instantiable-spatial-types.md#mutable-multi-objects-and-spatial-collections) +for signatures and examples. diff --git a/docs/instantiable-spatial-types.md b/docs/instantiable-spatial-types.md index 14ba00b..6a0f4a1 100644 --- a/docs/instantiable-spatial-types.md +++ b/docs/instantiable-spatial-types.md @@ -941,3 +941,107 @@ EMPTY or incomplete patches, open rings and null ordinates remain representable. Mutation does not enforce face validity, adjacency, closure or manifold rules. ISO/IEC CD 13249-3:201x(E), clause 8.5.1, defines polyhedral-surface validity; this representation API leaves its evaluation to explicit spatial-validator use. + +## Mutable multi-objects and spatial collections + +`MultiPoint`, `MultiLineString`, `MultiPolygon` and `SpatialCollection` are +available directly under each of `Types\Dimension2`, `Types\Dimension3z`, +`Types\Dimension3m` and `Types\Dimension4zm`. All four use this constructor: + +```php +new MultiPoint(array $elements = [], int|SpatialReference $srid = 0, + SpatialModelEnum $family = SpatialModelEnum::GEOMETRY); +``` + +| Mutable aggregate | Accepted members | Coordinate-array input | +| --- | --- | --- | +| `MultiPoint` | `MutablePointInterface` | Point tuples | +| `MultiLineString` | `MutableLineStringInterface` | Arrays of points or tuples | +| `MultiPolygon` | `MutablePolygonInterface` | Arrays of rings, points or tuples | +| `SpatialCollection` | `MutableSpatialInterface`, including nested collections | Not accepted: arrays do not identify member types | + +Construction and `setElements()` support the listed coordinate arrays. Arrays +create mutable descendants in the receiver's current layout, family and +reference. Supplied spatial objects retain their own context, layout and +identity. Immutable members and immutable descendants are rejected without +conversion or cloning. A mutable triangle implements `MutablePolygonInterface` +and can therefore be retained by a mutable multi-polygon. + +`SpatialCollection` is the family-independent mutable counterpart of +`GeometryCollectionImmutable` and `GeographyCollectionImmutable`. Changing its +family preserves both its concrete class and its instance identity. + +| Operation, common to all four kinds | Result | +| --- | --- | +| `addElement($element)` | Append a mutable member | +| `insertElement($index, $element)` | Insert before an index; the member count appends | +| `setElement($index, $element)` | Replace one existing member | +| `removeElement($index)` | Remove one existing member and reindex | +| `setElements($elements)` | Replace complete ordered membership atomically; `[]` empties it | +| `getElement($index)`, `getElements()` | Expose the retained mutable instances | + +`MultiPoint::getPoints()`, `MultiLineString::getLineStrings()` and +`MultiPolygon::getPolygons()` also expose the retained instances. Single-member +mutations take mutable spatial objects; coordinate arrays are accepted by the +constructor and complete replacement. Indices start at zero. Access, +replacement and removal require existing members; insertion accepts indices +from zero through the count. Invalid addressing raises `OutOfBoundsException`. +Wrong mutable member kinds, invalid membership arrays and immutable descendants +raise `InvalidValueException`; immutable arguments to typed single-member +mutators raise PHP `TypeError`. Technical input or addressing failures preserve +membership and referenced descendants, including late failures during complete +replacement. Successful mutations return the receiver as `static`. + +Containment must be acyclic. Construction and every member-mutation path reject +already cyclic member graphs or a new direct/indirect reference back to the +receiver with `InvalidValueException` and the message +`Mutable aggregate members must not form reference cycles.` The failure changes +neither membership nor the context of any referenced instance. This is a +technical object-graph restriction, independent of geometric validation. +Repeated members and shared descendants in an acyclic graph remain supported. + +```php +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Types\Dimension2\LineString; +use LongitudeOne\SpatialTypes\Types\Dimension2\MultiLineString; +use LongitudeOne\SpatialTypes\Types\Dimension2\MultiPoint; +use LongitudeOne\SpatialTypes\Types\Dimension2\Point; +use LongitudeOne\SpatialTypes\Types\Dimension2\SpatialCollection; + +$point = new Point(0, null, 4326); +$points = new MultiPoint([$point], 4326); +$line = new LineString([$point], 4326); +$lines = new MultiLineString([$line], 4326); +$collection = new SpatialCollection([$points, $lines], 4326); + +$points->addElement(new Point(3, 4))->insertElement(1, $point); +$point->setX(7); // Both occurrences in $points and the point in $line change. +assert($point === $points->getElement(0)); +assert([[[7, null]]] === $lines->toArray()); + +$collection->setFamily(SpatialModelEnum::GEOGRAPHY, false); // Only this collection. +$collection->setSrid(2154); // Nested aggregates, their members and all points. +assert(2154 === $point->getSrid()); +assert([7, null] === $point->toArray()); // Coordinates are unchanged. +``` + +`setSrid()`, `setSpatialReference()` and `setFamily()` each accept +`bool $propagate = true`. By default changes reach all descendants recursively, +including points in compound curves and polyhedral surfaces held by a +`SpatialCollection`. Propagation updates existing shared instances and is visible +to other owners of those instances. Passing `false` updates only the receiver. +Membership arrays returned by getters are PHP array values: editing that array +does not edit membership, while editing a returned spatial object does. + +`toArray()` preserves member boundaries, including nested collection boundaries. +Complete coordinate replacement creates new mutable descendants and detaches the +previous members from this receiver without modifying their other owners. +Spatial type and coordinate layout remain fixed. Mutable aggregates expose no +`with…` operations or dimension setter. + +EMPTY members, incomplete ordinates, duplicate points, open rings and other +geometrically invalid representable data are retained. Editing does not validate, +repair, transform or convert dimensions. ISO/IEC CD 13249-3:201x(E), clause 9.1.1, +defines the collection model and its validity requirements, including shared +spatial references. This representation API leaves those functional checks to +explicit [spatial-validator](validators.md) use. diff --git a/docs/type-class-hierarchy.md b/docs/type-class-hierarchy.md index 5c204fc..b848d83 100644 --- a/docs/type-class-hierarchy.md +++ b/docs/type-class-hierarchy.md @@ -114,3 +114,27 @@ AbstractSpatialType addressing. The base retains patches and their mutable descendants by reference. Coordinate arrays create mutable polygons in the fixed XYZ or XYZM layout; immutable surface implementations and their copy contracts remain separate. + +Mutable multi-objects and heterogeneous collections share ordered membership: + +```text +AbstractSpatialType +└── AbstractMutableCollection + ├── AbstractMultiPoint + │ └── Dimension{2,3m,3z,4zm}\MultiPoint + ├── AbstractMultiLineString + │ └── Dimension{2,3m,3z,4zm}\MultiLineString + ├── AbstractMultiPolygon + │ └── Dimension{2,3m,3z,4zm}\MultiPolygon + └── AbstractSpatialCollection + └── Dimension{2,3m,3z,4zm}\SpatialCollection +``` + +`MutableCollectionInterface` provides ordered member editing and recursive +context propagation. Each `MutableMultiInterface` refines member reads to +the corresponding mutable type. `SpatialCollection` accepts mutable spatial +objects, including nested collections, through `MutableSpatialInterface`. +`MutableComponentGuard` rejects immutable descendants without geometric checks. +Mutable implementations remain independent of immutable classes and expose no +copy operations. The public operations and ownership rules are documented in +[mutable multi-objects and spatial collections](instantiable-spatial-types.md#mutable-multi-objects-and-spatial-collections). diff --git a/lib/Interfaces/MutableCollectionInterface.php b/lib/Interfaces/MutableCollectionInterface.php index 30f421b..3497217 100644 --- a/lib/Interfaces/MutableCollectionInterface.php +++ b/lib/Interfaces/MutableCollectionInterface.php @@ -16,7 +16,68 @@ namespace LongitudeOne\SpatialTypes\Interfaces; -/** Mutable collection contract; type-specific editing is introduced separately. */ +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Reference\SpatialReference; + +/** Ordered mutable membership, retaining supplied instances. + * @template T of MutableSpatialInterface + * @template TInput + */ interface MutableCollectionInterface extends CollectionInterface, MutableSpatialInterface { + /** + * @param MutableSpatialInterface $element Member retained without copying + * + * @phpstan-param T $element */ + public function addElement(MutableSpatialInterface $element): static; + + /** + * @param int $index Existing zero-based member index + * + * @return T + */ + public function getElement(int $index): MutableSpatialInterface; + + /** @return list */ + public function getElements(): array; + + /** + * @param int $index Insertion index, including the member count + * @param MutableSpatialInterface $element Member retained without copying + * + * @phpstan-param T $element + */ + public function insertElement(int $index, MutableSpatialInterface $element): static; + + /** @param int $index Existing zero-based member index */ + public function removeElement(int $index): static; + + /** + * @param int $index Existing zero-based member index + * @param MutableSpatialInterface $element Replacement retained without copying + * + * @phpstan-param T $element + */ + public function setElement(int $index, MutableSpatialInterface $element): static; + + /** @param array $elements Complete ordered membership, including supported coordinate arrays */ + public function setElements(array $elements): static; + + /** + * @param SpatialModelEnum $family Declared family + * @param bool $propagate Whether to update all descendants + */ + public function setFamily(SpatialModelEnum $family, bool $propagate = true): static; + + /** + * @param SpatialReference $reference Declared reference + * @param bool $propagate Whether to update all descendants + */ + public function setSpatialReference(SpatialReference $reference, bool $propagate = true): static; + + /** + * @param int $srid Declared reference identifier + * @param bool $propagate Whether to update all descendants + */ + public function setSrid(int $srid, bool $propagate = true): static; } diff --git a/lib/Interfaces/MutableMultiLineStringInterface.php b/lib/Interfaces/MutableMultiLineStringInterface.php index bddc63e..dc54f2d 100644 --- a/lib/Interfaces/MutableMultiLineStringInterface.php +++ b/lib/Interfaces/MutableMultiLineStringInterface.php @@ -16,7 +16,14 @@ namespace LongitudeOne\SpatialTypes\Interfaces; -/** Mutable multilinestring contract; type-specific editing is introduced separately. */ +/** Mutable MultiLineString membership. + * @extends MutableCollectionInterface> + */ interface MutableMultiLineStringInterface extends MultiLineStringInterface, MutableCollectionInterface { + /** @return list */ + public function getElements(): array; + + /** @return list */ + public function getLineStrings(): array; } diff --git a/lib/Interfaces/MutableMultiPointInterface.php b/lib/Interfaces/MutableMultiPointInterface.php index b7334c2..f06d776 100644 --- a/lib/Interfaces/MutableMultiPointInterface.php +++ b/lib/Interfaces/MutableMultiPointInterface.php @@ -16,7 +16,14 @@ namespace LongitudeOne\SpatialTypes\Interfaces; -/** Mutable multipoint contract; type-specific editing is introduced separately. */ +/** Mutable MultiPoint membership. + * @extends MutableCollectionInterface + */ interface MutableMultiPointInterface extends MultiPointInterface, MutableCollectionInterface { + /** @return list */ + public function getElements(): array; + + /** @return list */ + public function getPoints(): array; } diff --git a/lib/Interfaces/MutableMultiPolygonInterface.php b/lib/Interfaces/MutableMultiPolygonInterface.php index 07d9171..bc8bf7b 100644 --- a/lib/Interfaces/MutableMultiPolygonInterface.php +++ b/lib/Interfaces/MutableMultiPolygonInterface.php @@ -16,7 +16,14 @@ namespace LongitudeOne\SpatialTypes\Interfaces; -/** Mutable multipolygon contract; type-specific editing is introduced separately. */ +/** Mutable MultiPolygon membership. + * @extends MutableCollectionInterface|\LongitudeOne\SpatialTypes\Interfaces\MutableLineStringInterface>> + */ interface MutableMultiPolygonInterface extends MultiPolygonInterface, MutableCollectionInterface { + /** @return list */ + public function getElements(): array; + + /** @return list */ + public function getPolygons(): array; } diff --git a/lib/Types/AbstractMultiLineString.php b/lib/Types/AbstractMultiLineString.php new file mode 100644 index 0000000..6f680c4 --- /dev/null +++ b/lib/Types/AbstractMultiLineString.php @@ -0,0 +1,69 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\Core\Enum\GeometryTypeEnum; +use LongitudeOne\SpatialTypes\Exception\InvalidValueException; +use LongitudeOne\SpatialTypes\Interfaces\MutableLineStringInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutableMultiLineStringInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutablePointInterface; + +/** Mutable MultiLineString members without geometric validation. + * @internal + * + * @extends AbstractMutableCollection> + */ +abstract class AbstractMultiLineString extends AbstractMutableCollection implements MutableMultiLineStringInterface +{ + /** @return list */ + public function getLineStrings(): array + { + return $this->getElements(); + } + + /** Return the fixed spatial type. */ + public function getType(): GeometryTypeEnum + { + return GeometryTypeEnum::MULTILINESTRING; + } + + /** @return (null|float|int)[][][] */ + public function toArray(): array + { + return array_map(static fn (MutableLineStringInterface $element): array => $element->toArray(), $this->getElements()); + } + + /** @param array|MutableLineStringInterface $element Mutable member or coordinate array */ + protected function hydrateElement(mixed $element): MutableLineStringInterface + { + if (is_array($element)) { + $element = match ($this->getDimension()) { + CoordinateDimensionEnum::XY => new Dimension2\LineString($element, $this->spatialReference, $this->family), + CoordinateDimensionEnum::XYZ => new Dimension3z\LineString($element, $this->spatialReference, $this->family), + CoordinateDimensionEnum::XYM => new Dimension3m\LineString($element, $this->spatialReference, $this->family), + CoordinateDimensionEnum::XYZM => new Dimension4zm\LineString($element, $this->spatialReference, $this->family), + }; + } + if (!$element instanceof MutableLineStringInterface) { + throw new InvalidValueException('Mutable MultiLineString members must be mutable LineString objects or coordinate arrays.'); + } + MutableComponentGuard::check($element, $this); + + return $element; + } +} diff --git a/lib/Types/AbstractMultiPoint.php b/lib/Types/AbstractMultiPoint.php new file mode 100644 index 0000000..5883006 --- /dev/null +++ b/lib/Types/AbstractMultiPoint.php @@ -0,0 +1,78 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types; + +use LongitudeOne\Core\Enum\GeometryTypeEnum; +use LongitudeOne\SpatialTypes\Exception\InvalidValueException; +use LongitudeOne\SpatialTypes\Factory\SpatialContext; +use LongitudeOne\SpatialTypes\Interfaces\MutableMultiPointInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutablePointInterface; + +/** Mutable MultiPoint members without geometric validation. + * @internal + * + * @extends AbstractMutableCollection + */ +abstract class AbstractMultiPoint extends AbstractMutableCollection implements MutableMultiPointInterface +{ + /** @return list */ + public function getPoints(): array + { + return $this->getElements(); + } + + /** Return the fixed spatial type. */ + public function getType(): GeometryTypeEnum + { + return GeometryTypeEnum::MULTIPOINT; + } + + /** Whether no two member positions contain equal points. */ + public function isSimple(): bool + { + $points = $this->getPoints(); + foreach ($points as $index => $point) { + foreach ($points as $otherIndex => $otherPoint) { + if ($index !== $otherIndex && $point->equalsTo($otherPoint)) { + return false; + } + } + } + + return true; + } + + /** @return (null|float|int)[][] */ + public function toArray(): array + { + return array_map(static fn (MutablePointInterface $element): array => $element->toArray(), $this->getElements()); + } + + /** @param array{0: null|float|int|string, 1: null|float|int|string, 2?: null|float|int, 3?: null|float|int}|MutablePointInterface $element Mutable member or coordinate array */ + protected function hydrateElement(mixed $element): MutablePointInterface + { + if (is_array($element)) { + $element = MutablePointHydrator::hydrate($element, new SpatialContext($this->spatialReference, $this->family, $this->getDimension())); + } + if (!$element instanceof MutablePointInterface) { + throw new InvalidValueException('Mutable MultiPoint members must be mutable Point objects or coordinate arrays.'); + } + MutableComponentGuard::check($element, $this); + + return $element; + } +} diff --git a/lib/Types/AbstractMultiPolygon.php b/lib/Types/AbstractMultiPolygon.php new file mode 100644 index 0000000..fbc4174 --- /dev/null +++ b/lib/Types/AbstractMultiPolygon.php @@ -0,0 +1,70 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\Core\Enum\GeometryTypeEnum; +use LongitudeOne\SpatialTypes\Exception\InvalidValueException; +use LongitudeOne\SpatialTypes\Interfaces\MutableLineStringInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutableMultiPolygonInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutablePointInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutablePolygonInterface; + +/** Mutable MultiPolygon members without geometric validation. + * @internal + * + * @extends AbstractMutableCollection|\LongitudeOne\SpatialTypes\Interfaces\MutableLineStringInterface>> + */ +abstract class AbstractMultiPolygon extends AbstractMutableCollection implements MutableMultiPolygonInterface +{ + /** @return list */ + public function getPolygons(): array + { + return $this->getElements(); + } + + /** Return the fixed spatial type. */ + public function getType(): GeometryTypeEnum + { + return GeometryTypeEnum::MULTIPOLYGON; + } + + /** @return (null|float|int)[][][][] */ + public function toArray(): array + { + return array_map(static fn (MutablePolygonInterface $element): array => $element->toArray(), $this->getElements()); + } + + /** @param array|MutableLineStringInterface>|MutablePolygonInterface $element Mutable member or coordinate array */ + protected function hydrateElement(mixed $element): MutablePolygonInterface + { + if (is_array($element)) { + $element = match ($this->getDimension()) { + CoordinateDimensionEnum::XY => new Dimension2\Polygon($element, $this->spatialReference, $this->family), + CoordinateDimensionEnum::XYZ => new Dimension3z\Polygon($element, $this->spatialReference, $this->family), + CoordinateDimensionEnum::XYM => new Dimension3m\Polygon($element, $this->spatialReference, $this->family), + CoordinateDimensionEnum::XYZM => new Dimension4zm\Polygon($element, $this->spatialReference, $this->family), + }; + } + if (!$element instanceof MutablePolygonInterface) { + throw new InvalidValueException('Mutable MultiPolygon members must be mutable Polygon objects or coordinate arrays.'); + } + MutableComponentGuard::check($element, $this); + + return $element; + } +} diff --git a/lib/Types/AbstractMutableCollection.php b/lib/Types/AbstractMutableCollection.php new file mode 100644 index 0000000..1677302 --- /dev/null +++ b/lib/Types/AbstractMutableCollection.php @@ -0,0 +1,201 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types; + +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Exception\OutOfBoundsException; +use LongitudeOne\SpatialTypes\Interfaces\MutableCollectionInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutableSpatialInterface; +use LongitudeOne\SpatialTypes\Reference\SpatialReference; + +/** Shared atomic ordered membership and declared context. + * @internal + * + * @template T of MutableSpatialInterface + * @template TInput + * + * @implements MutableCollectionInterface + */ +abstract class AbstractMutableCollection extends AbstractSpatialType implements MutableCollectionInterface +{ + protected SpatialModelEnum $family; + + /** @var list */ + private array $elements = []; + + /** + * @param array $elements Ordered members or supported coordinate arrays + * @param int|SpatialReference $srid Declared reference + * @param SpatialModelEnum $family Declared family + */ + final public function __construct(array $elements = [], int|SpatialReference $srid = 0, SpatialModelEnum $family = SpatialModelEnum::GEOMETRY) + { + $this->family = $family; + $this->initializeSpatialReference($srid); + $this->setElements($elements); + } + + /** + * @param MutableSpatialInterface $element Member retained without copying + * + * @phpstan-param T $element */ + public function addElement(MutableSpatialInterface $element): static + { + $this->elements[] = $this->hydrateElement($element); + + return $this; + } + + /** + * @param int $index Existing zero-based member index + * + * @return T + */ + public function getElement(int $index): MutableSpatialInterface + { + $this->requireElementIndex($index); + + return $this->elements[$index]; + } + + /** @return list */ + public function getElements(): array + { + return $this->elements; + } + + /** Return the declared family. */ + public function getFamily(): SpatialModelEnum + { + return $this->family; + } + + /** + * @param int $index Insertion index, including the member count + * @param MutableSpatialInterface $element Member retained without copying + * + * @phpstan-param T $element + */ + public function insertElement(int $index, MutableSpatialInterface $element): static + { + if ($index < 0 || $index > count($this->elements)) { + throw new OutOfBoundsException(sprintf('Member index %d is outside the insertion range.', $index)); + } + $checked = $this->hydrateElement($element); + array_splice($this->elements, $index, 0, [$checked]); + + return $this; + } + + /** Whether membership is empty. */ + public function isEmpty(): bool + { + return [] === $this->elements; + } + + /** @param int $index Existing zero-based member index */ + public function removeElement(int $index): static + { + $this->requireElementIndex($index); + array_splice($this->elements, $index, 1); + + return $this; + } + + /** + * @param int $index Existing zero-based member index + * @param MutableSpatialInterface $element Replacement retained without copying + * + * @phpstan-param T $element + */ + public function setElement(int $index, MutableSpatialInterface $element): static + { + $this->requireElementIndex($index); + $checked = $this->hydrateElement($element); + array_splice($this->elements, $index, 1, [$checked]); + + return $this; + } + + /** @param array $elements Complete ordered membership */ + public function setElements(array $elements): static + { + $checked = []; + foreach ($elements as $element) { + $checked[] = $this->hydrateElement($element); + } + $this->elements = $checked; + + return $this; + } + + /** + * @param SpatialModelEnum $family Declared family + * @param bool $propagate Whether to update all descendants + */ + public function setFamily(SpatialModelEnum $family, bool $propagate = true): static + { + if ($propagate) { + foreach ($this->elements as $element) { + $element->setFamily($family); + } + } + $this->family = $family; + + return $this; + } + + /** + * @param SpatialReference $reference Declared reference + * @param bool $propagate Whether to update all descendants + */ + public function setSpatialReference(SpatialReference $reference, bool $propagate = true): static + { + if ($propagate) { + foreach ($this->elements as $element) { + $element->setSpatialReference($reference); + } + } + $this->spatialReference = $reference; + + return $this; + } + + /** + * @param int $srid Declared reference identifier + * @param bool $propagate Whether to update all descendants + */ + public function setSrid(int $srid, bool $propagate = true): static + { + return $this->setSpatialReference(SpatialReference::fromSrid($srid), $propagate); + } + + /** @param int $index Existing zero-based member index */ + private function requireElementIndex(int $index): void + { + if ($index < 0 || $index >= count($this->elements)) { + throw new OutOfBoundsException(sprintf('Member index %d does not address an existing member.', $index)); + } + } + + /** + * @param T|TInput $element Member or supported coordinate array + * + * @return T + */ + abstract protected function hydrateElement(mixed $element): MutableSpatialInterface; +} diff --git a/lib/Types/AbstractSpatialCollection.php b/lib/Types/AbstractSpatialCollection.php new file mode 100644 index 0000000..483cd28 --- /dev/null +++ b/lib/Types/AbstractSpatialCollection.php @@ -0,0 +1,54 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types; + +use LongitudeOne\Core\Enum\GeometryTypeEnum; +use LongitudeOne\SpatialTypes\Exception\InvalidValueException; +use LongitudeOne\SpatialTypes\Interfaces\MutableSpatialInterface; + +/** Family-independent mutable spatial collection. + * @internal + * + * @extends AbstractMutableCollection + */ +abstract class AbstractSpatialCollection extends AbstractMutableCollection +{ + /** Return the fixed collection type. */ + public function getType(): GeometryTypeEnum + { + return GeometryTypeEnum::GEOMETRYCOLLECTION; + } + + /** Return ordered member representations, preserving arbitrary collection nesting. + * @return array + */ + public function toArray(): array + { + return array_map(static fn (MutableSpatialInterface $element): array => $element->toArray(), $this->getElements()); + } + + /** @param mixed $element Mutable spatial object retained without copying */ + protected function hydrateElement(mixed $element): MutableSpatialInterface + { + if (!$element instanceof MutableSpatialInterface) { + throw new InvalidValueException('Mutable spatial collections require mutable spatial objects.'); + } + MutableComponentGuard::check($element, $this); + + return $element; + } +} diff --git a/lib/Types/Dimension2/MultiLineString.php b/lib/Types/Dimension2/MultiLineString.php new file mode 100644 index 0000000..40b036d --- /dev/null +++ b/lib/Types/Dimension2/MultiLineString.php @@ -0,0 +1,30 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types\Dimension2; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Types\AbstractMultiLineString; + +/** Mutable XY MultiLineString with instance-level declared family. */ +final class MultiLineString extends AbstractMultiLineString +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XY; + } +} diff --git a/lib/Types/Dimension2/MultiPoint.php b/lib/Types/Dimension2/MultiPoint.php new file mode 100644 index 0000000..3d2b371 --- /dev/null +++ b/lib/Types/Dimension2/MultiPoint.php @@ -0,0 +1,30 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types\Dimension2; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Types\AbstractMultiPoint; + +/** Mutable XY MultiPoint with instance-level declared family. */ +final class MultiPoint extends AbstractMultiPoint +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XY; + } +} diff --git a/lib/Types/Dimension2/MultiPolygon.php b/lib/Types/Dimension2/MultiPolygon.php new file mode 100644 index 0000000..acd54ac --- /dev/null +++ b/lib/Types/Dimension2/MultiPolygon.php @@ -0,0 +1,30 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types\Dimension2; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Types\AbstractMultiPolygon; + +/** Mutable XY MultiPolygon with instance-level declared family. */ +final class MultiPolygon extends AbstractMultiPolygon +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XY; + } +} diff --git a/lib/Types/Dimension2/SpatialCollection.php b/lib/Types/Dimension2/SpatialCollection.php new file mode 100644 index 0000000..bb66190 --- /dev/null +++ b/lib/Types/Dimension2/SpatialCollection.php @@ -0,0 +1,30 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types\Dimension2; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Types\AbstractSpatialCollection; + +/** Mutable XY SpatialCollection with instance-level declared family. */ +final class SpatialCollection extends AbstractSpatialCollection +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XY; + } +} diff --git a/lib/Types/Dimension3m/MultiLineString.php b/lib/Types/Dimension3m/MultiLineString.php new file mode 100644 index 0000000..f7b4d4d --- /dev/null +++ b/lib/Types/Dimension3m/MultiLineString.php @@ -0,0 +1,30 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types\Dimension3m; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Types\AbstractMultiLineString; + +/** Mutable XYM MultiLineString with instance-level declared family. */ +final class MultiLineString extends AbstractMultiLineString +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XYM; + } +} diff --git a/lib/Types/Dimension3m/MultiPoint.php b/lib/Types/Dimension3m/MultiPoint.php new file mode 100644 index 0000000..29b7323 --- /dev/null +++ b/lib/Types/Dimension3m/MultiPoint.php @@ -0,0 +1,30 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types\Dimension3m; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Types\AbstractMultiPoint; + +/** Mutable XYM MultiPoint with instance-level declared family. */ +final class MultiPoint extends AbstractMultiPoint +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XYM; + } +} diff --git a/lib/Types/Dimension3m/MultiPolygon.php b/lib/Types/Dimension3m/MultiPolygon.php new file mode 100644 index 0000000..74e3195 --- /dev/null +++ b/lib/Types/Dimension3m/MultiPolygon.php @@ -0,0 +1,30 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types\Dimension3m; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Types\AbstractMultiPolygon; + +/** Mutable XYM MultiPolygon with instance-level declared family. */ +final class MultiPolygon extends AbstractMultiPolygon +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XYM; + } +} diff --git a/lib/Types/Dimension3m/SpatialCollection.php b/lib/Types/Dimension3m/SpatialCollection.php new file mode 100644 index 0000000..30f6c79 --- /dev/null +++ b/lib/Types/Dimension3m/SpatialCollection.php @@ -0,0 +1,30 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types\Dimension3m; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Types\AbstractSpatialCollection; + +/** Mutable XYM SpatialCollection with instance-level declared family. */ +final class SpatialCollection extends AbstractSpatialCollection +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XYM; + } +} diff --git a/lib/Types/Dimension3z/MultiLineString.php b/lib/Types/Dimension3z/MultiLineString.php new file mode 100644 index 0000000..ab44fd6 --- /dev/null +++ b/lib/Types/Dimension3z/MultiLineString.php @@ -0,0 +1,30 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types\Dimension3z; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Types\AbstractMultiLineString; + +/** Mutable XYZ MultiLineString with instance-level declared family. */ +final class MultiLineString extends AbstractMultiLineString +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XYZ; + } +} diff --git a/lib/Types/Dimension3z/MultiPoint.php b/lib/Types/Dimension3z/MultiPoint.php new file mode 100644 index 0000000..7c1c7fc --- /dev/null +++ b/lib/Types/Dimension3z/MultiPoint.php @@ -0,0 +1,30 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types\Dimension3z; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Types\AbstractMultiPoint; + +/** Mutable XYZ MultiPoint with instance-level declared family. */ +final class MultiPoint extends AbstractMultiPoint +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XYZ; + } +} diff --git a/lib/Types/Dimension3z/MultiPolygon.php b/lib/Types/Dimension3z/MultiPolygon.php new file mode 100644 index 0000000..00aaf52 --- /dev/null +++ b/lib/Types/Dimension3z/MultiPolygon.php @@ -0,0 +1,30 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types\Dimension3z; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Types\AbstractMultiPolygon; + +/** Mutable XYZ MultiPolygon with instance-level declared family. */ +final class MultiPolygon extends AbstractMultiPolygon +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XYZ; + } +} diff --git a/lib/Types/Dimension3z/SpatialCollection.php b/lib/Types/Dimension3z/SpatialCollection.php new file mode 100644 index 0000000..c9834f9 --- /dev/null +++ b/lib/Types/Dimension3z/SpatialCollection.php @@ -0,0 +1,30 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types\Dimension3z; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Types\AbstractSpatialCollection; + +/** Mutable XYZ SpatialCollection with instance-level declared family. */ +final class SpatialCollection extends AbstractSpatialCollection +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XYZ; + } +} diff --git a/lib/Types/Dimension4zm/MultiLineString.php b/lib/Types/Dimension4zm/MultiLineString.php new file mode 100644 index 0000000..db3dc00 --- /dev/null +++ b/lib/Types/Dimension4zm/MultiLineString.php @@ -0,0 +1,30 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types\Dimension4zm; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Types\AbstractMultiLineString; + +/** Mutable XYZM MultiLineString with instance-level declared family. */ +final class MultiLineString extends AbstractMultiLineString +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XYZM; + } +} diff --git a/lib/Types/Dimension4zm/MultiPoint.php b/lib/Types/Dimension4zm/MultiPoint.php new file mode 100644 index 0000000..e9ae567 --- /dev/null +++ b/lib/Types/Dimension4zm/MultiPoint.php @@ -0,0 +1,30 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types\Dimension4zm; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Types\AbstractMultiPoint; + +/** Mutable XYZM MultiPoint with instance-level declared family. */ +final class MultiPoint extends AbstractMultiPoint +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XYZM; + } +} diff --git a/lib/Types/Dimension4zm/MultiPolygon.php b/lib/Types/Dimension4zm/MultiPolygon.php new file mode 100644 index 0000000..e122cf2 --- /dev/null +++ b/lib/Types/Dimension4zm/MultiPolygon.php @@ -0,0 +1,30 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types\Dimension4zm; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Types\AbstractMultiPolygon; + +/** Mutable XYZM MultiPolygon with instance-level declared family. */ +final class MultiPolygon extends AbstractMultiPolygon +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XYZM; + } +} diff --git a/lib/Types/Dimension4zm/SpatialCollection.php b/lib/Types/Dimension4zm/SpatialCollection.php new file mode 100644 index 0000000..d3f044e --- /dev/null +++ b/lib/Types/Dimension4zm/SpatialCollection.php @@ -0,0 +1,30 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types\Dimension4zm; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\SpatialTypes\Types\AbstractSpatialCollection; + +/** Mutable XYZM SpatialCollection with instance-level declared family. */ +final class SpatialCollection extends AbstractSpatialCollection +{ + /** Return the fixed coordinate layout. */ + public function getDimension(): CoordinateDimensionEnum + { + return CoordinateDimensionEnum::XYZM; + } +} diff --git a/lib/Types/MutableComponentGuard.php b/lib/Types/MutableComponentGuard.php new file mode 100644 index 0000000..07f589d --- /dev/null +++ b/lib/Types/MutableComponentGuard.php @@ -0,0 +1,102 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Types; + +use LongitudeOne\SpatialTypes\Exception\InvalidValueException; +use LongitudeOne\SpatialTypes\Interfaces\CircularStringInterface; +use LongitudeOne\SpatialTypes\Interfaces\CollectionInterface; +use LongitudeOne\SpatialTypes\Interfaces\CompoundCurveInterface; +use LongitudeOne\SpatialTypes\Interfaces\CurveInterface; +use LongitudeOne\SpatialTypes\Interfaces\ImmutableSpatialInterface; +use LongitudeOne\SpatialTypes\Interfaces\LineStringInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutableSpatialInterface; +use LongitudeOne\SpatialTypes\Interfaces\PolygonInterface; +use LongitudeOne\SpatialTypes\Interfaces\PolyhedralSurfaceInterface; +use LongitudeOne\SpatialTypes\Interfaces\SpatialInterface; + +/** Checks mutable component ownership without checking geometric validity. + * @internal + */ +final class MutableComponentGuard +{ + /** Reject immutable components and containment cycles at every nesting level. + * @param SpatialInterface $component Component to inspect + * @param null|SpatialInterface $receiver Aggregate receiving the component + */ + public static function check(SpatialInterface $component, ?SpatialInterface $receiver = null): void + { + $states = []; + if (null !== $receiver) { + $states[spl_object_id($receiver)] = false; + } + self::inspect($component, $states); + } + + /** Distinguish active ancestors from completed shared components. + * @param SpatialInterface $component Component to inspect + * @param array $states False while visiting, true once inspection completes + */ + private static function inspect(SpatialInterface $component, array &$states): void + { + if (!$component instanceof MutableSpatialInterface || $component instanceof ImmutableSpatialInterface) { + throw new InvalidValueException('Mutable aggregates require exclusively mutable spatial components.'); + } + $identity = spl_object_id($component); + if (self::isComplete($identity, $states)) { + return; + } + $states[$identity] = false; + if ($component instanceof CurveInterface) { + $start = $component->getStartPoint(); + $end = $component->getEndPoint(); + if (null !== $start) { + self::inspect($start, $states); + } + if (null !== $end) { + self::inspect($end, $states); + } + } + if ($component instanceof CollectionInterface || $component instanceof LineStringInterface + || $component instanceof CircularStringInterface || $component instanceof CompoundCurveInterface + || $component instanceof PolyhedralSurfaceInterface) { + foreach ($component->getElements() as $child) { + self::inspect($child, $states); + } + } elseif ($component instanceof PolygonInterface) { + foreach ($component->getRings() as $ring) { + self::inspect($ring, $states); + } + } + $states[$identity] = true; + } + + /** Return whether inspection completed, rejecting an active ancestor. + * @param int $identity Component object identifier + * @param array $states False while visiting, true once inspection completes + */ + private static function isComplete(int $identity, array $states): bool + { + if (!array_key_exists($identity, $states)) { + return false; + } + if (!$states[$identity]) { + throw new InvalidValueException('Mutable aggregate members must not form reference cycles.'); + } + + return true; + } +} diff --git a/tests/Unit/Types/MutableMultiObjectContextTest.php b/tests/Unit/Types/MutableMultiObjectContextTest.php new file mode 100644 index 0000000..cff9f84 --- /dev/null +++ b/tests/Unit/Types/MutableMultiObjectContextTest.php @@ -0,0 +1,250 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Tests\Unit\Types; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Reference\SpatialReference; +use LongitudeOne\SpatialTypes\Types\Dimension2\LineString; +use LongitudeOne\SpatialTypes\Types\Dimension2\MultiLineString; +use LongitudeOne\SpatialTypes\Types\Dimension2\MultiPoint; +use LongitudeOne\SpatialTypes\Types\Dimension2\MultiPolygon; +use LongitudeOne\SpatialTypes\Types\Dimension2\Point; +use LongitudeOne\SpatialTypes\Types\Dimension2\Polygon; +use LongitudeOne\SpatialTypes\Types\Dimension2\SpatialCollection; +use PHPUnit\Framework\TestCase; + +/** Mutable aggregate contracts. + * @internal + * + * @coversNothing + */ +class MutableMultiObjectContextTest extends TestCase +{ + /** Both propagation settings preserve coordinates and shared instance identity. */ + public function testMultiLineStringFamily(): void + { + $point = new Point(0, null, 4326); + $aggregate = new MultiLineString([new LineString([$point], 4326)], 4326); + $owner = new SpatialCollection([$point, $aggregate]); + static::assertSame($aggregate, $aggregate->setFamily(SpatialModelEnum::GEOGRAPHY, false)); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $aggregate->getFamily()); + static::assertSame([SpatialModelEnum::GEOMETRY], [$point->getFamily()]); + static::assertSame($point, $aggregate->getElement(0)->getPoint(0)); + static::assertSame($aggregate, $aggregate->setFamily(SpatialModelEnum::GEOGRAPHY)); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $owner->getElement(0)->getFamily()); + static::assertSame(0, $owner->getSrid()); + static::assertSame([0, null], $point->toArray()); + static::assertSame(CoordinateDimensionEnum::XY, $aggregate->getDimension()); + } + + /** Both propagation settings preserve coordinates and shared instance identity. */ + public function testMultiLineStringSpatialReference(): void + { + $point = new Point(0, null, 4326); + $aggregate = new MultiLineString([new LineString([$point], 4326)], 4326); + $owner = new SpatialCollection([$point, $aggregate]); + static::assertSame($aggregate, $aggregate->setSpatialReference(SpatialReference::epsg(2154), false)); + static::assertSame('EPSG', $aggregate->getSpatialReference()->authority); + static::assertNull($point->getSpatialReference()->authority); + static::assertSame($point, $aggregate->getElement(0)->getPoint(0)); + static::assertSame($aggregate, $aggregate->setSpatialReference(SpatialReference::epsg(2154))); + static::assertSame('EPSG', $point->getSpatialReference()->authority); + static::assertSame('EPSG', $owner->getElement(0)->getSpatialReference()->authority); + static::assertSame(0, $owner->getSrid()); + static::assertSame([0, null], $point->toArray()); + static::assertSame(CoordinateDimensionEnum::XY, $aggregate->getDimension()); + } + + /** Both propagation settings preserve coordinates and shared instance identity. */ + public function testMultiLineStringSrid(): void + { + $point = new Point(0, null, 4326); + $aggregate = new MultiLineString([new LineString([$point], 4326)], 4326); + $owner = new SpatialCollection([$point, $aggregate]); + static::assertSame($aggregate, $aggregate->setSrid(2154, false)); + static::assertSame(2154, $aggregate->getSrid()); + static::assertSame(4326, $point->getSrid()); + static::assertSame($point, $aggregate->getElement(0)->getPoint(0)); + static::assertSame($aggregate, $aggregate->setSrid(2154)); + static::assertSame(2154, $point->getSrid()); + static::assertSame(2154, $owner->getElement(0)->getSrid()); + static::assertSame(0, $owner->getSrid()); + static::assertSame([0, null], $point->toArray()); + static::assertSame(CoordinateDimensionEnum::XY, $aggregate->getDimension()); + } + + /** Both propagation settings preserve coordinates and shared instance identity. */ + public function testMultiPointFamily(): void + { + $point = new Point(0, null, 4326); + $aggregate = new MultiPoint([$point], 4326); + $owner = new SpatialCollection([$point, $aggregate]); + static::assertSame($aggregate, $aggregate->setFamily(SpatialModelEnum::GEOGRAPHY, false)); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $aggregate->getFamily()); + static::assertSame([SpatialModelEnum::GEOMETRY], [$point->getFamily()]); + static::assertSame($point, $aggregate->getElement(0)); + static::assertSame($aggregate, $aggregate->setFamily(SpatialModelEnum::GEOGRAPHY)); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $owner->getElement(0)->getFamily()); + static::assertSame(0, $owner->getSrid()); + static::assertSame([0, null], $point->toArray()); + static::assertSame(CoordinateDimensionEnum::XY, $aggregate->getDimension()); + } + + /** Both propagation settings preserve coordinates and shared instance identity. */ + public function testMultiPointSpatialReference(): void + { + $point = new Point(0, null, 4326); + $aggregate = new MultiPoint([$point], 4326); + $owner = new SpatialCollection([$point, $aggregate]); + static::assertSame($aggregate, $aggregate->setSpatialReference(SpatialReference::epsg(2154), false)); + static::assertSame('EPSG', $aggregate->getSpatialReference()->authority); + static::assertNull($point->getSpatialReference()->authority); + static::assertSame($point, $aggregate->getElement(0)); + static::assertSame($aggregate, $aggregate->setSpatialReference(SpatialReference::epsg(2154))); + static::assertSame('EPSG', $point->getSpatialReference()->authority); + static::assertSame('EPSG', $owner->getElement(0)->getSpatialReference()->authority); + static::assertSame(0, $owner->getSrid()); + static::assertSame([0, null], $point->toArray()); + static::assertSame(CoordinateDimensionEnum::XY, $aggregate->getDimension()); + } + + /** Both propagation settings preserve coordinates and shared instance identity. */ + public function testMultiPointSrid(): void + { + $point = new Point(0, null, 4326); + $aggregate = new MultiPoint([$point], 4326); + $owner = new SpatialCollection([$point, $aggregate]); + static::assertSame($aggregate, $aggregate->setSrid(2154, false)); + static::assertSame(2154, $aggregate->getSrid()); + static::assertSame(4326, $point->getSrid()); + static::assertSame($point, $aggregate->getElement(0)); + static::assertSame($aggregate, $aggregate->setSrid(2154)); + static::assertSame(2154, $point->getSrid()); + static::assertSame(2154, $owner->getElement(0)->getSrid()); + static::assertSame(0, $owner->getSrid()); + static::assertSame([0, null], $point->toArray()); + static::assertSame(CoordinateDimensionEnum::XY, $aggregate->getDimension()); + } + + /** Both propagation settings preserve coordinates and shared instance identity. */ + public function testMultiPolygonFamily(): void + { + $point = new Point(0, null, 4326); + $aggregate = new MultiPolygon([new Polygon([new LineString([$point], 4326)], 4326)], 4326); + $owner = new SpatialCollection([$point, $aggregate]); + static::assertSame($aggregate, $aggregate->setFamily(SpatialModelEnum::GEOGRAPHY, false)); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $aggregate->getFamily()); + static::assertSame([SpatialModelEnum::GEOMETRY], [$point->getFamily()]); + static::assertSame($point, $aggregate->getElement(0)->getRing(0)->getPoint(0)); + static::assertSame($aggregate, $aggregate->setFamily(SpatialModelEnum::GEOGRAPHY)); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $owner->getElement(0)->getFamily()); + static::assertSame(0, $owner->getSrid()); + static::assertSame([0, null], $point->toArray()); + static::assertSame(CoordinateDimensionEnum::XY, $aggregate->getDimension()); + } + + /** Both propagation settings preserve coordinates and shared instance identity. */ + public function testMultiPolygonSpatialReference(): void + { + $point = new Point(0, null, 4326); + $aggregate = new MultiPolygon([new Polygon([new LineString([$point], 4326)], 4326)], 4326); + $owner = new SpatialCollection([$point, $aggregate]); + static::assertSame($aggregate, $aggregate->setSpatialReference(SpatialReference::epsg(2154), false)); + static::assertSame('EPSG', $aggregate->getSpatialReference()->authority); + static::assertNull($point->getSpatialReference()->authority); + static::assertSame($point, $aggregate->getElement(0)->getRing(0)->getPoint(0)); + static::assertSame($aggregate, $aggregate->setSpatialReference(SpatialReference::epsg(2154))); + static::assertSame('EPSG', $point->getSpatialReference()->authority); + static::assertSame('EPSG', $owner->getElement(0)->getSpatialReference()->authority); + static::assertSame(0, $owner->getSrid()); + static::assertSame([0, null], $point->toArray()); + static::assertSame(CoordinateDimensionEnum::XY, $aggregate->getDimension()); + } + + /** Both propagation settings preserve coordinates and shared instance identity. */ + public function testMultiPolygonSrid(): void + { + $point = new Point(0, null, 4326); + $aggregate = new MultiPolygon([new Polygon([new LineString([$point], 4326)], 4326)], 4326); + $owner = new SpatialCollection([$point, $aggregate]); + static::assertSame($aggregate, $aggregate->setSrid(2154, false)); + static::assertSame(2154, $aggregate->getSrid()); + static::assertSame(4326, $point->getSrid()); + static::assertSame($point, $aggregate->getElement(0)->getRing(0)->getPoint(0)); + static::assertSame($aggregate, $aggregate->setSrid(2154)); + static::assertSame(2154, $point->getSrid()); + static::assertSame(2154, $owner->getElement(0)->getSrid()); + static::assertSame(0, $owner->getSrid()); + static::assertSame([0, null], $point->toArray()); + static::assertSame(CoordinateDimensionEnum::XY, $aggregate->getDimension()); + } + + /** Both propagation settings preserve coordinates and shared instance identity. */ + public function testSpatialCollectionFamily(): void + { + $point = new Point(0, null, 4326); + $aggregate = new SpatialCollection([new SpatialCollection([new MultiPolygon([new Polygon([new LineString([$point], 4326)], 4326)], 4326)], 4326)], 4326); + $owner = new SpatialCollection([$point, $aggregate]); + static::assertSame($aggregate, $aggregate->setFamily(SpatialModelEnum::GEOGRAPHY, false)); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $aggregate->getFamily()); + static::assertSame([SpatialModelEnum::GEOMETRY], [$point->getFamily()]); + static::assertSame($aggregate, $aggregate->setFamily(SpatialModelEnum::GEOGRAPHY)); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $owner->getElement(0)->getFamily()); + static::assertSame(0, $owner->getSrid()); + static::assertSame([0, null], $point->toArray()); + static::assertSame(CoordinateDimensionEnum::XY, $aggregate->getDimension()); + } + + /** Both propagation settings preserve coordinates and shared instance identity. */ + public function testSpatialCollectionSpatialReference(): void + { + $point = new Point(0, null, 4326); + $aggregate = new SpatialCollection([new SpatialCollection([new MultiPolygon([new Polygon([new LineString([$point], 4326)], 4326)], 4326)], 4326)], 4326); + $owner = new SpatialCollection([$point, $aggregate]); + static::assertSame($aggregate, $aggregate->setSpatialReference(SpatialReference::epsg(2154), false)); + static::assertSame('EPSG', $aggregate->getSpatialReference()->authority); + static::assertNull($point->getSpatialReference()->authority); + static::assertSame($aggregate, $aggregate->setSpatialReference(SpatialReference::epsg(2154))); + static::assertSame('EPSG', $point->getSpatialReference()->authority); + static::assertSame('EPSG', $owner->getElement(0)->getSpatialReference()->authority); + static::assertSame(0, $owner->getSrid()); + static::assertSame([0, null], $point->toArray()); + static::assertSame(CoordinateDimensionEnum::XY, $aggregate->getDimension()); + } + + /** Both propagation settings preserve coordinates and shared instance identity. */ + public function testSpatialCollectionSrid(): void + { + $point = new Point(0, null, 4326); + $aggregate = new SpatialCollection([new SpatialCollection([new MultiPolygon([new Polygon([new LineString([$point], 4326)], 4326)], 4326)], 4326)], 4326); + $owner = new SpatialCollection([$point, $aggregate]); + static::assertSame($aggregate, $aggregate->setSrid(2154, false)); + static::assertSame(2154, $aggregate->getSrid()); + static::assertSame(4326, $point->getSrid()); + static::assertSame($aggregate, $aggregate->setSrid(2154)); + static::assertSame(2154, $point->getSrid()); + static::assertSame(2154, $owner->getElement(0)->getSrid()); + static::assertSame(0, $owner->getSrid()); + static::assertSame([0, null], $point->toArray()); + static::assertSame(CoordinateDimensionEnum::XY, $aggregate->getDimension()); + } +} diff --git a/tests/Unit/Types/MutableMultiObjectFailureTest.php b/tests/Unit/Types/MutableMultiObjectFailureTest.php new file mode 100644 index 0000000..cd27995 --- /dev/null +++ b/tests/Unit/Types/MutableMultiObjectFailureTest.php @@ -0,0 +1,375 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Tests\Unit\Types; + +use LongitudeOne\SpatialTypes\Exception\InvalidDimensionException; +use LongitudeOne\SpatialTypes\Exception\InvalidValueException; +use LongitudeOne\SpatialTypes\Exception\OutOfBoundsException; +use LongitudeOne\SpatialTypes\Interfaces\MutableLineStringInterface; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\LineStringImmutable; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\PointImmutable; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\PolygonImmutable; +use LongitudeOne\SpatialTypes\Types\Dimension2\LineString; +use LongitudeOne\SpatialTypes\Types\Dimension2\MultiLineString; +use LongitudeOne\SpatialTypes\Types\Dimension2\MultiPoint; +use LongitudeOne\SpatialTypes\Types\Dimension2\MultiPolygon; +use LongitudeOne\SpatialTypes\Types\Dimension2\Point; +use LongitudeOne\SpatialTypes\Types\Dimension2\Polygon; +use LongitudeOne\SpatialTypes\Types\Dimension2\SpatialCollection; +use PHPUnit\Framework\TestCase; + +/** Mutable aggregate contracts. + * @internal + * + * @coversNothing + */ +class MutableMultiObjectFailureTest extends TestCase +{ + /** Heterogeneous coordinate arrays carry no type information and are rejected. */ + public function testCollectionCoordinateArrayRejection(): void + { + $aggregate = new SpatialCollection([]); + $this->expectException(InvalidValueException::class); + + try { + (new \ReflectionMethod($aggregate, 'setElements'))->invoke($aggregate, [[1, 2]]); + } finally { + static::assertTrue($aggregate->isEmpty()); + } + } + + /** Custom mutable aggregate objects cannot conceal immutable descendants. */ + public function testCustomImmutableDescendant(): void + { + $immutable = new PointImmutable(1, 2, 4326); + $line = static::createStub(MutableLineStringInterface::class); + $line->method('getElements')->willReturn([$immutable]); + $aggregate = new SpatialCollection([]); + $this->expectException(InvalidValueException::class); + + try { + $aggregate->addElement($line); + } finally { + static::assertSame([], $aggregate->getElements()); + static::assertSame([1, 2], $immutable->toArray()); + } + } + + /** Invalid addressing preserves the aggregate and its referenced member. */ + public function testgetElementBeyondRange(): void + { + $point = new Point(1, null, 4326); + $replacement = new Point(7, 8, 3857); + $aggregate = new MultiPoint([$point], 2154); + $this->expectException(OutOfBoundsException::class); + + try { + $aggregate->getElement(1); + } finally { + static::assertSame([$point], $aggregate->getElements()); + static::assertSame([[1, null]], $aggregate->toArray()); + static::assertSame(4326, $point->getSrid()); + static::assertSame([7, 8], $replacement->toArray()); + } + } + + /** Invalid addressing preserves the aggregate and its referenced member. */ + public function testgetElementNegative(): void + { + $point = new Point(1, null, 4326); + $replacement = new Point(7, 8, 3857); + $aggregate = new MultiPoint([$point], 2154); + $this->expectException(OutOfBoundsException::class); + + try { + $aggregate->getElement(-1); + } finally { + static::assertSame([$point], $aggregate->getElements()); + static::assertSame([[1, null]], $aggregate->toArray()); + static::assertSame(4326, $point->getSrid()); + static::assertSame([7, 8], $replacement->toArray()); + } + } + + /** An immutable member is rejected by the typed insertion API. */ + public function testImmutableTypedInsertion(): void + { + $point = new Point(1, null, 4326); + $aggregate = new SpatialCollection([$point]); + $immutable = new PointImmutable(7, 8, 4326); + $this->expectException(\TypeError::class); + + try { + (new \ReflectionMethod($aggregate, 'insertElement'))->invoke($aggregate, 1, $immutable); + } finally { + static::assertSame([$point], $aggregate->getElements()); + static::assertSame([7, 8], $immutable->toArray()); + } + } + + /** Invalid addressing preserves the aggregate and its referenced member. */ + public function testinsertElementBeyondRange(): void + { + $point = new Point(1, null, 4326); + $replacement = new Point(7, 8, 3857); + $aggregate = new MultiPoint([$point], 2154); + $this->expectException(OutOfBoundsException::class); + + try { + $aggregate->insertElement(2, $replacement); + } finally { + static::assertSame([$point], $aggregate->getElements()); + static::assertSame([[1, null]], $aggregate->toArray()); + static::assertSame(4326, $point->getSrid()); + static::assertSame([7, 8], $replacement->toArray()); + } + } + + /** Invalid addressing preserves the aggregate and its referenced member. */ + public function testinsertElementNegative(): void + { + $point = new Point(1, null, 4326); + $replacement = new Point(7, 8, 3857); + $aggregate = new MultiPoint([$point], 2154); + $this->expectException(OutOfBoundsException::class); + + try { + $aggregate->insertElement(-1, $replacement); + } finally { + static::assertSame([$point], $aggregate->getElements()); + static::assertSame([[1, null]], $aggregate->toArray()); + static::assertSame(4326, $point->getSrid()); + static::assertSame([7, 8], $replacement->toArray()); + } + } + + /** A late wrong-layout tuple never replaces existing shared membership. */ + public function testMultiLineStringCoordinateFailure(): void + { + $member = new LineString([[1, null]], 4326); + $aggregate = new MultiLineString([$member], 2154); + $this->expectException(InvalidDimensionException::class); + + try { + (new \ReflectionMethod($aggregate, 'setElements'))->invoke($aggregate, [[[7, 8]], [[1, 2, 3]]]); + } finally { + static::assertSame([$member], $aggregate->getElements()); + static::assertSame(4326, $member->getSrid()); + static::assertSame(2154, $aggregate->getSrid()); + } + } + + /** Late immutable membership fails atomically without altering shared members. */ + public function testMultiLineStringImmutableRejection(): void + { + $member = new LineString([[1, null]], 4326); + $immutable = new LineStringImmutable([[1, 2]], 4326); + $aggregate = new MultiLineString([$member], 2154); + $owner = new SpatialCollection([$member]); + $this->expectException(InvalidValueException::class); + + try { + (new \ReflectionMethod($aggregate, 'setElements'))->invoke($aggregate, [$member, $immutable]); + } finally { + static::assertSame([$member], $aggregate->getElements()); + static::assertSame([$member], $owner->getElements()); + static::assertSame(4326, $member->getSrid()); + static::assertSame(4326, $immutable->getSrid()); + static::assertSame(2154, $aggregate->getSrid()); + } + } + + /** A late wrong-layout tuple never replaces existing shared membership. */ + public function testMultiPointCoordinateFailure(): void + { + $member = new Point(1, null, 4326); + $aggregate = new MultiPoint([$member], 2154); + $this->expectException(InvalidDimensionException::class); + + try { + (new \ReflectionMethod($aggregate, 'setElements'))->invoke($aggregate, [[7, 8], [1, 2, 3]]); + } finally { + static::assertSame([$member], $aggregate->getElements()); + static::assertSame(4326, $member->getSrid()); + static::assertSame(2154, $aggregate->getSrid()); + } + } + + /** Late immutable membership fails atomically without altering shared members. */ + public function testMultiPointImmutableRejection(): void + { + $member = new Point(1, null, 4326); + $immutable = new PointImmutable(1, 2, 4326); + $aggregate = new MultiPoint([$member], 2154); + $owner = new SpatialCollection([$member]); + $this->expectException(InvalidValueException::class); + + try { + (new \ReflectionMethod($aggregate, 'setElements'))->invoke($aggregate, [$member, $immutable]); + } finally { + static::assertSame([$member], $aggregate->getElements()); + static::assertSame([$member], $owner->getElements()); + static::assertSame(4326, $member->getSrid()); + static::assertSame(4326, $immutable->getSrid()); + static::assertSame(2154, $aggregate->getSrid()); + } + } + + /** A late wrong-layout tuple never replaces existing shared membership. */ + public function testMultiPolygonCoordinateFailure(): void + { + $member = new Polygon([[[1, null]]], 4326); + $aggregate = new MultiPolygon([$member], 2154); + $this->expectException(InvalidDimensionException::class); + + try { + (new \ReflectionMethod($aggregate, 'setElements'))->invoke($aggregate, [[[[7, 8]]], [[[1, 2, 3]]]]); + } finally { + static::assertSame([$member], $aggregate->getElements()); + static::assertSame(4326, $member->getSrid()); + static::assertSame(2154, $aggregate->getSrid()); + } + } + + /** Late immutable membership fails atomically without altering shared members. */ + public function testMultiPolygonImmutableRejection(): void + { + $member = new Polygon([[[1, null]]], 4326); + $immutable = new PolygonImmutable([[[1, 2]]], 4326); + $aggregate = new MultiPolygon([$member], 2154); + $owner = new SpatialCollection([$member]); + $this->expectException(InvalidValueException::class); + + try { + (new \ReflectionMethod($aggregate, 'setElements'))->invoke($aggregate, [$member, $immutable]); + } finally { + static::assertSame([$member], $aggregate->getElements()); + static::assertSame([$member], $owner->getElements()); + static::assertSame(4326, $member->getSrid()); + static::assertSame(4326, $immutable->getSrid()); + static::assertSame(2154, $aggregate->getSrid()); + } + } + + /** Invalid addressing preserves the aggregate and its referenced member. */ + public function testremoveElementBeyondRange(): void + { + $point = new Point(1, null, 4326); + $replacement = new Point(7, 8, 3857); + $aggregate = new MultiPoint([$point], 2154); + $this->expectException(OutOfBoundsException::class); + + try { + $aggregate->removeElement(1); + } finally { + static::assertSame([$point], $aggregate->getElements()); + static::assertSame([[1, null]], $aggregate->toArray()); + static::assertSame(4326, $point->getSrid()); + static::assertSame([7, 8], $replacement->toArray()); + } + } + + /** Invalid addressing preserves the aggregate and its referenced member. */ + public function testremoveElementNegative(): void + { + $point = new Point(1, null, 4326); + $replacement = new Point(7, 8, 3857); + $aggregate = new MultiPoint([$point], 2154); + $this->expectException(OutOfBoundsException::class); + + try { + $aggregate->removeElement(-1); + } finally { + static::assertSame([$point], $aggregate->getElements()); + static::assertSame([[1, null]], $aggregate->toArray()); + static::assertSame(4326, $point->getSrid()); + static::assertSame([7, 8], $replacement->toArray()); + } + } + + /** Invalid addressing preserves the aggregate and its referenced member. */ + public function testsetElementBeyondRange(): void + { + $point = new Point(1, null, 4326); + $replacement = new Point(7, 8, 3857); + $aggregate = new MultiPoint([$point], 2154); + $this->expectException(OutOfBoundsException::class); + + try { + $aggregate->setElement(1, $replacement); + } finally { + static::assertSame([$point], $aggregate->getElements()); + static::assertSame([[1, null]], $aggregate->toArray()); + static::assertSame(4326, $point->getSrid()); + static::assertSame([7, 8], $replacement->toArray()); + } + } + + /** Invalid addressing preserves the aggregate and its referenced member. */ + public function testsetElementNegative(): void + { + $point = new Point(1, null, 4326); + $replacement = new Point(7, 8, 3857); + $aggregate = new MultiPoint([$point], 2154); + $this->expectException(OutOfBoundsException::class); + + try { + $aggregate->setElement(-1, $replacement); + } finally { + static::assertSame([$point], $aggregate->getElements()); + static::assertSame([[1, null]], $aggregate->toArray()); + static::assertSame(4326, $point->getSrid()); + static::assertSame([7, 8], $replacement->toArray()); + } + } + + /** Late immutable membership fails atomically without altering shared members. */ + public function testSpatialCollectionImmutableRejection(): void + { + $member = new Point(1, null, 4326); + $immutable = new PointImmutable(1, 2, 4326); + $aggregate = new SpatialCollection([$member], 2154); + $owner = new SpatialCollection([$member]); + $this->expectException(InvalidValueException::class); + + try { + (new \ReflectionMethod($aggregate, 'setElements'))->invoke($aggregate, [$member, $immutable]); + } finally { + static::assertSame([$member], $aggregate->getElements()); + static::assertSame([$member], $owner->getElements()); + static::assertSame(4326, $member->getSrid()); + static::assertSame(4326, $immutable->getSrid()); + static::assertSame(2154, $aggregate->getSrid()); + } + } + + /** Typed spatial input of the wrong member kind is rejected before mutation. */ + public function testWrongMutableKind(): void + { + $point = new Point(1, null, 4326); + $aggregate = new MultiPoint([$point]); + $line = new LineString([$point], 4326); + $this->expectException(InvalidValueException::class); + + try { + (new \ReflectionMethod($aggregate, 'addElement'))->invoke($aggregate, $line); + } finally { + static::assertSame([$point], $aggregate->getElements()); + static::assertSame([$point], $line->getPoints()); + } + } +} diff --git a/tests/Unit/Types/MutableMultiObjectLayoutTest.php b/tests/Unit/Types/MutableMultiObjectLayoutTest.php new file mode 100644 index 0000000..84a5d61 --- /dev/null +++ b/tests/Unit/Types/MutableMultiObjectLayoutTest.php @@ -0,0 +1,219 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Tests\Unit\Types; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\Core\Enum\GeometryTypeEnum; +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Interfaces\MutableMultiLineStringInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutableMultiPointInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutableMultiPolygonInterface; +use LongitudeOne\SpatialTypes\Interfaces\MutableSpatialInterface; +use LongitudeOne\SpatialTypes\Types\Dimension2\CircularString; +use LongitudeOne\SpatialTypes\Types\Dimension2\CompoundCurve; +use LongitudeOne\SpatialTypes\Types\Dimension2\LineString; +use LongitudeOne\SpatialTypes\Types\Dimension2\MultiLineString; +use LongitudeOne\SpatialTypes\Types\Dimension2\MultiPoint; +use LongitudeOne\SpatialTypes\Types\Dimension2\MultiPolygon; +use LongitudeOne\SpatialTypes\Types\Dimension2\Point; +use LongitudeOne\SpatialTypes\Types\Dimension2\Polygon; +use LongitudeOne\SpatialTypes\Types\Dimension2\SpatialCollection; +use LongitudeOne\SpatialTypes\Types\Dimension2\Triangle; +use LongitudeOne\SpatialTypes\Types\Dimension3z\PolyhedralSurface; +use PHPUnit\Framework\TestCase; + +/** Mutable aggregate contracts. + * @internal + * + * @coversNothing + */ +class MutableMultiObjectLayoutTest extends TestCase +{ + /** SpatialCollection accepts every supported mutable spatial type and nested collections. */ + public function testAllMutableMemberKinds(): void + { + $point = new Point(0, null); + $line = new LineString([$point]); + $arc = new CircularString([[1, 2]]); + $compound = new CompoundCurve([$line, $arc]); + $polygon = new Polygon([$line]); + $triangle = new Triangle([$line]); + $surface = new PolyhedralSurface([new \LongitudeOne\SpatialTypes\Types\Dimension3z\Polygon([[[1, 2, 3]]])]); + $points = new MultiPoint([$point]); + $lines = new MultiLineString([$line]); + $polygons = new MultiPolygon([$polygon]); + $nested = new SpatialCollection([]); + $collection = new SpatialCollection([$point, $line, $arc, $compound, $polygon, $triangle, $surface, $points, $lines, $polygons, $nested]); + static::assertSame([$point, $line, $arc, $compound, $polygon, $triangle, $surface, $points, $lines, $polygons, $nested], $collection->getElements()); + static::assertSame($collection, $collection->setFamily(SpatialModelEnum::GEOGRAPHY)->setSrid(2154)); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $arc->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $compound->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $triangle->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $surface->getFamily()); + static::assertSame(2154, $surface->getPatch(0)->getRing(0)->getPoint(0)->getSrid()); + static::assertSame(2154, $arc->getPoint(0)->getSrid()); + static::assertSame(2154, $nested->getSrid()); + static::assertSame([0, null], $point->toArray()); + } + + /** Empty members and invalid representable geometry are kept. */ + public function testEmptyMembers(): void + { + $points = new MultiPoint([[null, null], [0, null]]); + $lines = new MultiLineString([[], [[0, null]]]); + $polygons = new MultiPolygon([[], [[]], [[[0, null]]]]); + $collection = new SpatialCollection([$points, $lines, $polygons]); + static::assertFalse($points->isEmpty()); + static::assertSame([[], [0, null]], $points->toArray()); + static::assertSame([[], [[0, null]]], $lines->toArray()); + static::assertSame([[], [[]], [[[0, null]]]], $polygons->toArray()); + static::assertFalse($collection->isEmpty()); + static::assertSame([[[], [0, null]], [[], [[0, null]]], [[], [[]], [[[0, null]]]]], $collection->toArray()); + } + + /** Mutable aggregates expose their mutation contracts without copy or layout mutation methods. */ + public function testPublicContracts(): void + { + $points = new MultiPoint(); + $lines = new MultiLineString(); + $polygons = new MultiPolygon(); + $collection = new SpatialCollection(); + static::assertContains(MutableMultiPointInterface::class, class_implements($points)); + static::assertContains(MutableMultiLineStringInterface::class, class_implements($lines)); + static::assertContains(MutableMultiPolygonInterface::class, class_implements($polygons)); + static::assertContains(MutableSpatialInterface::class, class_implements($collection)); + static::assertSame(SpatialModelEnum::GEOMETRY, $points->getFamily()); + static::assertSame(SpatialModelEnum::GEOMETRY, $lines->getFamily()); + static::assertSame(SpatialModelEnum::GEOMETRY, $polygons->getFamily()); + static::assertSame(SpatialModelEnum::GEOMETRY, $collection->getFamily()); + static::assertFalse((new \ReflectionClass($points))->hasMethod('withPoint')); + static::assertFalse((new \ReflectionClass($lines))->hasMethod('withLineString')); + static::assertFalse((new \ReflectionClass($polygons))->hasMethod('withPolygon')); + static::assertFalse((new \ReflectionClass($collection))->hasMethod('withElement')); + static::assertFalse((new \ReflectionClass($collection))->hasMethod('withSrid')); + static::assertFalse((new \ReflectionClass($points))->hasMethod('setDimension')); + static::assertSame('static', (string) (new \ReflectionMethod($collection, 'setElements'))->getReturnType()); + } + + /** Each collection kind hydrates mutable descendants in the declared layout. */ + public function testXyClasses(): void + { + $points = new MultiPoint([[0, null]], 4326, SpatialModelEnum::GEOGRAPHY); + $lines = new MultiLineString([[[0, null]]], 4326, SpatialModelEnum::GEOGRAPHY); + $polygons = new MultiPolygon([[[[0, null]]]], 4326, SpatialModelEnum::GEOGRAPHY); + $collection = new SpatialCollection([$points, $lines, $polygons], 4326, SpatialModelEnum::GEOGRAPHY); + static::assertSame([[0, null]], $points->toArray()); + static::assertSame([[[0, null]]], $lines->toArray()); + static::assertSame([[[[0, null]]]], $polygons->toArray()); + static::assertSame(CoordinateDimensionEnum::XY, $points->getDimension()); + static::assertSame(CoordinateDimensionEnum::XY, $lines->getDimension()); + static::assertSame(CoordinateDimensionEnum::XY, $polygons->getDimension()); + static::assertSame(CoordinateDimensionEnum::XY, $collection->getDimension()); + static::assertInstanceOf(Point::class, $points->getElement(0)); + static::assertInstanceOf(LineString::class, $lines->getElement(0)); + static::assertInstanceOf(Polygon::class, $polygons->getElement(0)); + static::assertInstanceOf(Point::class, $polygons->getElement(0)->getRing(0)->getPoint(0)); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $polygons->getElement(0)->getRing(0)->getPoint(0)->getFamily()); + static::assertSame(4326, $polygons->getElement(0)->getRing(0)->getPoint(0)->getSrid()); + static::assertSame([$points, $lines, $polygons], $collection->getElements()); + static::assertSame(GeometryTypeEnum::MULTIPOINT, $points->getType()); + static::assertSame(GeometryTypeEnum::MULTILINESTRING, $lines->getType()); + static::assertSame(GeometryTypeEnum::MULTIPOLYGON, $polygons->getType()); + static::assertSame(GeometryTypeEnum::GEOMETRYCOLLECTION, $collection->getType()); + } + + /** Each collection kind hydrates mutable descendants in the declared layout. */ + public function testXymClasses(): void + { + $points = new \LongitudeOne\SpatialTypes\Types\Dimension3m\MultiPoint([[0, null, 7]], 4326, SpatialModelEnum::GEOGRAPHY); + $lines = new \LongitudeOne\SpatialTypes\Types\Dimension3m\MultiLineString([[[0, null, 7]]], 4326, SpatialModelEnum::GEOGRAPHY); + $polygons = new \LongitudeOne\SpatialTypes\Types\Dimension3m\MultiPolygon([[[[0, null, 7]]]], 4326, SpatialModelEnum::GEOGRAPHY); + $collection = new \LongitudeOne\SpatialTypes\Types\Dimension3m\SpatialCollection([$points, $lines, $polygons], 4326, SpatialModelEnum::GEOGRAPHY); + static::assertSame([[0, null, 7]], $points->toArray()); + static::assertSame([[[0, null, 7]]], $lines->toArray()); + static::assertSame([[[[0, null, 7]]]], $polygons->toArray()); + static::assertSame(CoordinateDimensionEnum::XYM, $points->getDimension()); + static::assertSame(CoordinateDimensionEnum::XYM, $lines->getDimension()); + static::assertSame(CoordinateDimensionEnum::XYM, $polygons->getDimension()); + static::assertSame(CoordinateDimensionEnum::XYM, $collection->getDimension()); + static::assertInstanceOf(\LongitudeOne\SpatialTypes\Types\Dimension3m\Point::class, $points->getElement(0)); + static::assertInstanceOf(\LongitudeOne\SpatialTypes\Types\Dimension3m\LineString::class, $lines->getElement(0)); + static::assertInstanceOf(\LongitudeOne\SpatialTypes\Types\Dimension3m\Polygon::class, $polygons->getElement(0)); + static::assertInstanceOf(\LongitudeOne\SpatialTypes\Types\Dimension3m\Point::class, $polygons->getElement(0)->getRing(0)->getPoint(0)); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $polygons->getElement(0)->getRing(0)->getPoint(0)->getFamily()); + static::assertSame(4326, $polygons->getElement(0)->getRing(0)->getPoint(0)->getSrid()); + static::assertSame([$points, $lines, $polygons], $collection->getElements()); + static::assertSame(GeometryTypeEnum::MULTIPOINT, $points->getType()); + static::assertSame(GeometryTypeEnum::MULTILINESTRING, $lines->getType()); + static::assertSame(GeometryTypeEnum::MULTIPOLYGON, $polygons->getType()); + static::assertSame(GeometryTypeEnum::GEOMETRYCOLLECTION, $collection->getType()); + } + + /** Each collection kind hydrates mutable descendants in the declared layout. */ + public function testXyzClasses(): void + { + $points = new \LongitudeOne\SpatialTypes\Types\Dimension3z\MultiPoint([[0, null, 5]], 4326, SpatialModelEnum::GEOGRAPHY); + $lines = new \LongitudeOne\SpatialTypes\Types\Dimension3z\MultiLineString([[[0, null, 5]]], 4326, SpatialModelEnum::GEOGRAPHY); + $polygons = new \LongitudeOne\SpatialTypes\Types\Dimension3z\MultiPolygon([[[[0, null, 5]]]], 4326, SpatialModelEnum::GEOGRAPHY); + $collection = new \LongitudeOne\SpatialTypes\Types\Dimension3z\SpatialCollection([$points, $lines, $polygons], 4326, SpatialModelEnum::GEOGRAPHY); + static::assertSame([[0, null, 5]], $points->toArray()); + static::assertSame([[[0, null, 5]]], $lines->toArray()); + static::assertSame([[[[0, null, 5]]]], $polygons->toArray()); + static::assertSame(CoordinateDimensionEnum::XYZ, $points->getDimension()); + static::assertSame(CoordinateDimensionEnum::XYZ, $lines->getDimension()); + static::assertSame(CoordinateDimensionEnum::XYZ, $polygons->getDimension()); + static::assertSame(CoordinateDimensionEnum::XYZ, $collection->getDimension()); + static::assertInstanceOf(\LongitudeOne\SpatialTypes\Types\Dimension3z\Point::class, $points->getElement(0)); + static::assertInstanceOf(\LongitudeOne\SpatialTypes\Types\Dimension3z\LineString::class, $lines->getElement(0)); + static::assertInstanceOf(\LongitudeOne\SpatialTypes\Types\Dimension3z\Polygon::class, $polygons->getElement(0)); + static::assertInstanceOf(\LongitudeOne\SpatialTypes\Types\Dimension3z\Point::class, $polygons->getElement(0)->getRing(0)->getPoint(0)); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $polygons->getElement(0)->getRing(0)->getPoint(0)->getFamily()); + static::assertSame(4326, $polygons->getElement(0)->getRing(0)->getPoint(0)->getSrid()); + static::assertSame([$points, $lines, $polygons], $collection->getElements()); + static::assertSame(GeometryTypeEnum::MULTIPOINT, $points->getType()); + static::assertSame(GeometryTypeEnum::MULTILINESTRING, $lines->getType()); + static::assertSame(GeometryTypeEnum::MULTIPOLYGON, $polygons->getType()); + static::assertSame(GeometryTypeEnum::GEOMETRYCOLLECTION, $collection->getType()); + } + + /** Each collection kind hydrates mutable descendants in the declared layout. */ + public function testXyzmClasses(): void + { + $points = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\MultiPoint([[0, null, 5, 7]], 4326, SpatialModelEnum::GEOGRAPHY); + $lines = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\MultiLineString([[[0, null, 5, 7]]], 4326, SpatialModelEnum::GEOGRAPHY); + $polygons = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\MultiPolygon([[[[0, null, 5, 7]]]], 4326, SpatialModelEnum::GEOGRAPHY); + $collection = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\SpatialCollection([$points, $lines, $polygons], 4326, SpatialModelEnum::GEOGRAPHY); + static::assertSame([[0, null, 5, 7]], $points->toArray()); + static::assertSame([[[0, null, 5, 7]]], $lines->toArray()); + static::assertSame([[[[0, null, 5, 7]]]], $polygons->toArray()); + static::assertSame(CoordinateDimensionEnum::XYZM, $points->getDimension()); + static::assertSame(CoordinateDimensionEnum::XYZM, $lines->getDimension()); + static::assertSame(CoordinateDimensionEnum::XYZM, $polygons->getDimension()); + static::assertSame(CoordinateDimensionEnum::XYZM, $collection->getDimension()); + static::assertInstanceOf(\LongitudeOne\SpatialTypes\Types\Dimension4zm\Point::class, $points->getElement(0)); + static::assertInstanceOf(\LongitudeOne\SpatialTypes\Types\Dimension4zm\LineString::class, $lines->getElement(0)); + static::assertInstanceOf(\LongitudeOne\SpatialTypes\Types\Dimension4zm\Polygon::class, $polygons->getElement(0)); + static::assertInstanceOf(\LongitudeOne\SpatialTypes\Types\Dimension4zm\Point::class, $polygons->getElement(0)->getRing(0)->getPoint(0)); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $polygons->getElement(0)->getRing(0)->getPoint(0)->getFamily()); + static::assertSame(4326, $polygons->getElement(0)->getRing(0)->getPoint(0)->getSrid()); + static::assertSame([$points, $lines, $polygons], $collection->getElements()); + static::assertSame(GeometryTypeEnum::MULTIPOINT, $points->getType()); + static::assertSame(GeometryTypeEnum::MULTILINESTRING, $lines->getType()); + static::assertSame(GeometryTypeEnum::MULTIPOLYGON, $polygons->getType()); + static::assertSame(GeometryTypeEnum::GEOMETRYCOLLECTION, $collection->getType()); + } +} diff --git a/tests/Unit/Types/MutableMultiObjectOwnershipTest.php b/tests/Unit/Types/MutableMultiObjectOwnershipTest.php new file mode 100644 index 0000000..df9ec6d --- /dev/null +++ b/tests/Unit/Types/MutableMultiObjectOwnershipTest.php @@ -0,0 +1,168 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Tests\Unit\Types; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Exception\InvalidValueException; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\LineStringImmutable; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\MultiPointImmutable; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\PointImmutable; +use LongitudeOne\SpatialTypes\Types\Dimension2\Geometry\PolygonImmutable; +use LongitudeOne\SpatialTypes\Types\Dimension2\LineString; +use LongitudeOne\SpatialTypes\Types\Dimension2\MultiLineString; +use LongitudeOne\SpatialTypes\Types\Dimension2\MultiPoint; +use LongitudeOne\SpatialTypes\Types\Dimension2\MultiPolygon; +use LongitudeOne\SpatialTypes\Types\Dimension2\Point; +use LongitudeOne\SpatialTypes\Types\Dimension2\Polygon; +use LongitudeOne\SpatialTypes\Types\Dimension2\SpatialCollection; +use PHPUnit\Framework\TestCase; + +/** Mutable aggregate contracts. + * @internal + * + * @coversNothing + */ +class MutableMultiObjectOwnershipTest extends TestCase +{ + /** Complete array replacement detaches previous members without changing their other owners. */ + public function testCoordinateReplacementCreatesNewMembers(): void + { + $point = new Point(1, null, 4326); + $line = new LineString([$point], 4326); + $polygon = new Polygon([$line], 4326); + $points = new MultiPoint([$point], 2154, SpatialModelEnum::GEOGRAPHY); + $lines = new MultiLineString([$line], 2154, SpatialModelEnum::GEOGRAPHY); + $polygons = new MultiPolygon([$polygon], 2154, SpatialModelEnum::GEOGRAPHY); + $owner = new SpatialCollection([$point, $line, $polygon]); + static::assertSame($points, $points->setElements([[7, null]])); + static::assertSame($lines, $lines->setElements([[[8, null]]])); + static::assertSame($polygons, $polygons->setElements([[[[9, null]]]])); + static::assertSame([[7, null]], $points->toArray()); + static::assertSame([[[8, null]]], $lines->toArray()); + static::assertSame([[[[9, null]]]], $polygons->toArray()); + static::assertSame([[1, null], [[1, null]], [[[1, null]]]], $owner->toArray()); + static::assertSame([$point, $line, $polygon], $owner->getElements()); + static::assertSame(2154, $points->getElement(0)->getSrid()); + static::assertSame(2154, $lines->getElement(0)->getPoint(0)->getSrid()); + static::assertSame(2154, $polygons->getElement(0)->getRing(0)->getPoint(0)->getSrid()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $points->getElement(0)->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $lines->getElement(0)->getPoint(0)->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $polygons->getElement(0)->getRing(0)->getPoint(0)->getFamily()); + } + + /** Immutable multi-objects reject new mutable members without conversion. */ + public function testImmutableAggregateStillRejectsMutableMembers(): void + { + $point = new Point(1, null, 4326); + $this->expectException(InvalidValueException::class); + + try { + (new \ReflectionClass(MultiPointImmutable::class))->newInstance([$point]); + } finally { + static::assertSame([1, null], $point->toArray()); + static::assertSame(4326, $point->getSrid()); + } + } + + /** Coordinate-array paths reject immutable descendants instead of converting them. */ + public function testImmutableNestedPoint(): void + { + $immutable = new PointImmutable(1, 2, 4326); + $this->expectException(InvalidValueException::class); + + try { + (new \ReflectionClass(MultiLineString::class))->newInstance([[$immutable]]); + } finally { + static::assertSame([1, 2], $immutable->toArray()); + static::assertSame(4326, $immutable->getSrid()); + } + } + + /** Construction rejects immutable members without altering their source. */ + public function testMultiLineStringConstructorRejection(): void + { + $immutable = new LineStringImmutable([[1, 2]], 4326); + $this->expectException(InvalidValueException::class); + + try { + (new \ReflectionClass(MultiLineString::class))->newInstance([$immutable]); + } finally { + static::assertSame(4326, $immutable->getSrid()); + static::assertSame(SpatialModelEnum::GEOMETRY, $immutable->getFamily()); + } + } + + /** Construction rejects immutable members without altering their source. */ + public function testMultiPointConstructorRejection(): void + { + $immutable = new PointImmutable(1, 2, 4326); + $this->expectException(InvalidValueException::class); + + try { + (new \ReflectionClass(MultiPoint::class))->newInstance([$immutable]); + } finally { + static::assertSame(4326, $immutable->getSrid()); + static::assertSame(SpatialModelEnum::GEOMETRY, $immutable->getFamily()); + } + } + + /** Construction rejects immutable members without altering their source. */ + public function testMultiPolygonConstructorRejection(): void + { + $immutable = new PolygonImmutable([[[1, 2]]], 4326); + $this->expectException(InvalidValueException::class); + + try { + (new \ReflectionClass(MultiPolygon::class))->newInstance([$immutable]); + } finally { + static::assertSame(4326, $immutable->getSrid()); + static::assertSame(SpatialModelEnum::GEOMETRY, $immutable->getFamily()); + } + } + + /** Supplied mutable objects keep their own context and layout until explicitly changed. */ + public function testSharedMismatchedContext(): void + { + $point = new \LongitudeOne\SpatialTypes\Types\Dimension3m\Point(1, null, 7, 4326, SpatialModelEnum::GEOGRAPHY); + $points = new MultiPoint([$point], 2154); + static::assertSame($point, $points->getElement(0)); + static::assertSame([[1, null, 7]], $points->toArray()); + static::assertSame(CoordinateDimensionEnum::XY, $points->getDimension()); + static::assertSame(CoordinateDimensionEnum::XYM, $point->getDimension()); + static::assertSame(SpatialModelEnum::GEOMETRY, $points->getFamily()); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + static::assertSame(4326, $point->getSrid()); + static::assertSame($points, $points->setFamily(SpatialModelEnum::GEOMETRY)); + static::assertSame(SpatialModelEnum::GEOMETRY, $point->getFamily()); + static::assertSame([1, null, 7], $point->toArray()); + } + + /** Construction rejects immutable members without altering their source. */ + public function testSpatialCollectionConstructorRejection(): void + { + $immutable = new PointImmutable(1, 2, 4326); + $this->expectException(InvalidValueException::class); + + try { + (new \ReflectionClass(SpatialCollection::class))->newInstance([$immutable]); + } finally { + static::assertSame(4326, $immutable->getSrid()); + static::assertSame(SpatialModelEnum::GEOMETRY, $immutable->getFamily()); + } + } +} diff --git a/tests/Unit/Types/MutableMultiObjectTest.php b/tests/Unit/Types/MutableMultiObjectTest.php new file mode 100644 index 0000000..58789ae --- /dev/null +++ b/tests/Unit/Types/MutableMultiObjectTest.php @@ -0,0 +1,165 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Tests\Unit\Types; + +use LongitudeOne\SpatialTypes\Types\Dimension2\LineString; +use LongitudeOne\SpatialTypes\Types\Dimension2\MultiLineString; +use LongitudeOne\SpatialTypes\Types\Dimension2\MultiPoint; +use LongitudeOne\SpatialTypes\Types\Dimension2\MultiPolygon; +use LongitudeOne\SpatialTypes\Types\Dimension2\Point; +use LongitudeOne\SpatialTypes\Types\Dimension2\Polygon; +use LongitudeOne\SpatialTypes\Types\Dimension2\SpatialCollection; +use PHPUnit\Framework\TestCase; + +/** Mutable aggregate contracts. + * @internal + * + * @coversNothing + */ +class MutableMultiObjectTest extends TestCase +{ + /** Every membership edit keeps identity, order and zero-based addressing. */ + public function testMultiLineStringMembership(): void + { + $first = new LineString([[1, null]], 4326); + $second = new LineString([[7, 8]]); + $aggregate = new MultiLineString([7 => $first]); + static::assertSame($aggregate, $aggregate->addElement($first)->insertElement(1, $second)); + static::assertSame([$first, $second, $first], $aggregate->getLineStrings()); + static::assertSame([[[1, null]], [[7, 8]], [[1, null]]], $aggregate->toArray()); + static::assertSame($first, $aggregate->getElement(0)); + static::assertSame($aggregate, $aggregate->insertElement(3, $second)->setElement(0, $second)->removeElement(1)); + static::assertSame([$second, $first, $second], $aggregate->getElements()); + static::assertSame($aggregate, $aggregate->setElements([9 => $first, 4 => $second])); + static::assertSame([$first, $second], $aggregate->getElements()); + $returned = $aggregate->getElements(); + $returned[0] = $second; + static::assertSame($first, $aggregate->getElement(0)); + static::assertSame(4326, $first->getSrid()); + static::assertSame($aggregate, $aggregate->setElements([])); + static::assertTrue($aggregate->isEmpty()); + static::assertSame([], $aggregate->toArray()); + } + + /** Every membership edit keeps identity, order and zero-based addressing. */ + public function testMultiPointMembership(): void + { + $first = new Point(1, null, 4326); + $second = new Point(7, 8); + $aggregate = new MultiPoint([7 => $first]); + static::assertSame($aggregate, $aggregate->addElement($first)->insertElement(1, $second)); + static::assertSame([$first, $second, $first], $aggregate->getPoints()); + static::assertSame([[1, null], [7, 8], [1, null]], $aggregate->toArray()); + static::assertSame($first, $aggregate->getElement(0)); + static::assertSame($aggregate, $aggregate->insertElement(3, $second)->setElement(0, $second)->removeElement(1)); + static::assertSame([$second, $first, $second], $aggregate->getElements()); + static::assertSame($aggregate, $aggregate->setElements([9 => $first, 4 => $second])); + static::assertSame([$first, $second], $aggregate->getElements()); + $returned = $aggregate->getElements(); + $returned[0] = $second; + static::assertSame($first, $aggregate->getElement(0)); + static::assertSame(4326, $first->getSrid()); + static::assertSame($aggregate, $aggregate->setElements([])); + static::assertTrue($aggregate->isEmpty()); + static::assertSame([], $aggregate->toArray()); + } + + /** Every membership edit keeps identity, order and zero-based addressing. */ + public function testMultiPolygonMembership(): void + { + $first = new Polygon([[[1, null]]], 4326); + $second = new Polygon([[[7, 8]]]); + $aggregate = new MultiPolygon([7 => $first]); + static::assertSame($aggregate, $aggregate->addElement($first)->insertElement(1, $second)); + static::assertSame([$first, $second, $first], $aggregate->getPolygons()); + static::assertSame([[[[1, null]]], [[[7, 8]]], [[[1, null]]]], $aggregate->toArray()); + static::assertSame($first, $aggregate->getElement(0)); + static::assertSame($aggregate, $aggregate->insertElement(3, $second)->setElement(0, $second)->removeElement(1)); + static::assertSame([$second, $first, $second], $aggregate->getElements()); + static::assertSame($aggregate, $aggregate->setElements([9 => $first, 4 => $second])); + static::assertSame([$first, $second], $aggregate->getElements()); + $returned = $aggregate->getElements(); + $returned[0] = $second; + static::assertSame($first, $aggregate->getElement(0)); + static::assertSame(4326, $first->getSrid()); + static::assertSame($aggregate, $aggregate->setElements([])); + static::assertTrue($aggregate->isEmpty()); + static::assertSame([], $aggregate->toArray()); + } + + /** A shared descendant stays observable through different aggregate kinds. */ + public function testSharedDescendants(): void + { + $point = new Point(1, null, 4326); + $line = new LineString([$point], 4326); + $polygon = new Polygon([$line], 4326); + $points = new MultiPoint([$point]); + $lines = new MultiLineString([$line]); + $polygons = new MultiPolygon([$polygon]); + $nested = new SpatialCollection([$points, $lines, $polygons]); + $owner = new SpatialCollection([$nested, $polygon]); + $point->setX(9); + static::assertSame([[9, null]], $points->toArray()); + static::assertSame([[[9, null]]], $lines->toArray()); + static::assertSame([[[[9, null]]]], $polygons->toArray()); + static::assertSame($nested, $owner->getElement(0)); + static::assertSame($polygon, $polygons->getElement(0)); + static::assertSame($line, $polygon->getRing(0)); + static::assertSame($point, $points->getElement(0)); + $replacement = new Point(7, 8); + $points->setElement(0, $replacement); + static::assertSame([[7, 8]], $points->toArray()); + static::assertSame([[[9, null]]], $lines->toArray()); + } + + /** Duplicate member positions are retained and can be inspected explicitly. */ + public function testSimplicity(): void + { + $point = new Point(1, 2); + $points = new MultiPoint([$point, $point]); + static::assertFalse($points->isSimple()); + static::assertSame($points, $points->setElements([new Point(1, 2), new Point(1, 2)])); + static::assertFalse($points->isSimple()); + static::assertSame($points, $points->removeElement(1)); + static::assertTrue($points->isSimple()); + static::assertSame($points, $points->setElements([])); + static::assertTrue($points->isSimple()); + } + + /** Every membership edit keeps identity, order and zero-based addressing. */ + public function testSpatialCollectionMembership(): void + { + $first = new Point(1, null, 4326); + $second = new Point(7, 8); + $aggregate = new SpatialCollection([7 => $first]); + static::assertSame($aggregate, $aggregate->addElement($first)->insertElement(1, $second)); + static::assertSame([$first, $second, $first], $aggregate->getElements()); + static::assertSame([[1, null], [7, 8], [1, null]], $aggregate->toArray()); + static::assertSame($first, $aggregate->getElement(0)); + static::assertSame($aggregate, $aggregate->insertElement(3, $second)->setElement(0, $second)->removeElement(1)); + static::assertSame([$second, $first, $second], $aggregate->getElements()); + static::assertSame($aggregate, $aggregate->setElements([9 => $first, 4 => $second])); + static::assertSame([$first, $second], $aggregate->getElements()); + $returned = $aggregate->getElements(); + $returned[0] = $second; + static::assertSame($first, $aggregate->getElement(0)); + static::assertSame(4326, $first->getSrid()); + static::assertSame($aggregate, $aggregate->setElements([])); + static::assertTrue($aggregate->isEmpty()); + static::assertSame([], $aggregate->toArray()); + } +} diff --git a/tests/Unit/Types/MutableSpatialCollectionCycleTest.php b/tests/Unit/Types/MutableSpatialCollectionCycleTest.php new file mode 100644 index 0000000..76365ff --- /dev/null +++ b/tests/Unit/Types/MutableSpatialCollectionCycleTest.php @@ -0,0 +1,292 @@ + 2024-2026 + * Copyright Longitude One 2024-2026 + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + */ + +declare(strict_types=1); + +namespace LongitudeOne\SpatialTypes\Tests\Unit\Types; + +use LongitudeOne\Core\Enum\CoordinateDimensionEnum; +use LongitudeOne\Core\Enum\SpatialModelEnum; +use LongitudeOne\SpatialTypes\Exception\InvalidValueException; +use LongitudeOne\SpatialTypes\Interfaces\MutableCollectionInterface; +use LongitudeOne\SpatialTypes\Reference\SpatialReference; +use LongitudeOne\SpatialTypes\Types\Dimension2\Point; +use LongitudeOne\SpatialTypes\Types\Dimension2\SpatialCollection; +use PHPUnit\Framework\TestCase; + +/** Mutable collection graphs reject cycles while retaining shared descendants. + * @internal + * + * @coversNothing + */ +class MutableSpatialCollectionCycleTest extends TestCase +{ + /** Complete replacement rejects a cyclic candidate after a valid earlier member. */ + public function testAlreadyCyclicMemberReplacement(): void + { + $point = new Point(1, null, 4326); + $collection = new SpatialCollection([$point], 2154); + $cyclic = static::createStub(MutableCollectionInterface::class); + $cyclic->method('getElements')->willReturn([$cyclic]); + $this->expectException(InvalidValueException::class); + + try { + $collection->setElements([new Point(7, 8), $cyclic]); + } finally { + static::assertSame([$point], $collection->getElements()); + static::assertSame(2154, $collection->getSrid()); + static::assertSame(4326, $point->getSrid()); + } + } + + /** Pre-existing cyclic input from a custom component is rejected on construction. */ + public function testConstructorRejectsAlreadyCyclicMember(): void + { + $cyclic = static::createStub(MutableCollectionInterface::class); + $cyclic->method('getElements')->willReturn([$cyclic]); + $this->expectException(InvalidValueException::class); + $this->expectExceptionMessage('Mutable aggregate members must not form reference cycles.'); + new SpatialCollection([$cyclic]); + } + + /** Self-membership is rejected before any mutation. */ + public function testDirectAppend(): void + { + $point = new Point(1, null, 4326); + $collection = new SpatialCollection([$point], 2154); + $this->expectException(InvalidValueException::class); + $this->expectExceptionMessage('Mutable aggregate members must not form reference cycles.'); + + try { + $collection->addElement($collection); + } finally { + static::assertSame([$point], $collection->getElements()); + static::assertSame(2154, $collection->getSrid()); + static::assertSame(4326, $point->getSrid()); + static::assertSame([1, null], $point->toArray()); + } + } + + /** Self-membership is rejected before any mutation. */ + public function testDirectInsert(): void + { + $point = new Point(1, null, 4326); + $collection = new SpatialCollection([$point], 2154); + $this->expectException(InvalidValueException::class); + $this->expectExceptionMessage('Mutable aggregate members must not form reference cycles.'); + + try { + $collection->insertElement(1, $collection); + } finally { + static::assertSame([$point], $collection->getElements()); + static::assertSame(2154, $collection->getSrid()); + static::assertSame(4326, $point->getSrid()); + static::assertSame([1, null], $point->toArray()); + } + } + + /** Self-membership is rejected before any mutation. */ + public function testDirectReplace(): void + { + $point = new Point(1, null, 4326); + $collection = new SpatialCollection([$point], 2154); + $this->expectException(InvalidValueException::class); + $this->expectExceptionMessage('Mutable aggregate members must not form reference cycles.'); + + try { + $collection->setElement(0, $collection); + } finally { + static::assertSame([$point], $collection->getElements()); + static::assertSame(2154, $collection->getSrid()); + static::assertSame(4326, $point->getSrid()); + static::assertSame([1, null], $point->toArray()); + } + } + + /** Self-membership is rejected before any mutation. */ + public function testDirectReplaceAll(): void + { + $point = new Point(1, null, 4326); + $collection = new SpatialCollection([$point], 2154); + $this->expectException(InvalidValueException::class); + $this->expectExceptionMessage('Mutable aggregate members must not form reference cycles.'); + + try { + $collection->setElements([$point, $collection]); + } finally { + static::assertSame([$point], $collection->getElements()); + static::assertSame(2154, $collection->getSrid()); + static::assertSame(4326, $point->getSrid()); + static::assertSame([1, null], $point->toArray()); + } + } + + /** Cycle rejection is independent of the fixed coordinate layout. */ + public function testDirectXym(): void + { + $collection = new \LongitudeOne\SpatialTypes\Types\Dimension3m\SpatialCollection([], 4326); + $this->expectException(InvalidValueException::class); + + try { + $collection->addElement($collection); + } finally { + static::assertSame([], $collection->getElements()); + static::assertSame(4326, $collection->getSrid()); + static::assertSame(CoordinateDimensionEnum::XYM, $collection->getDimension()); + } + } + + /** Cycle rejection is independent of the fixed coordinate layout. */ + public function testDirectXyz(): void + { + $collection = new \LongitudeOne\SpatialTypes\Types\Dimension3z\SpatialCollection([], 4326); + $this->expectException(InvalidValueException::class); + + try { + $collection->addElement($collection); + } finally { + static::assertSame([], $collection->getElements()); + static::assertSame(4326, $collection->getSrid()); + static::assertSame(CoordinateDimensionEnum::XYZ, $collection->getDimension()); + } + } + + /** Cycle rejection is independent of the fixed coordinate layout. */ + public function testDirectXyzm(): void + { + $collection = new \LongitudeOne\SpatialTypes\Types\Dimension4zm\SpatialCollection([], 4326); + $this->expectException(InvalidValueException::class); + + try { + $collection->addElement($collection); + } finally { + static::assertSame([], $collection->getElements()); + static::assertSame(4326, $collection->getSrid()); + static::assertSame(CoordinateDimensionEnum::XYZM, $collection->getDimension()); + } + } + + /** A member referencing the receiver cannot close a membership cycle. */ + public function testIndirectAppend(): void + { + $point = new Point(1, null, 4326); + $collection = new SpatialCollection([$point], 2154); + $bridge = new SpatialCollection([$collection], 3857); + $nested = new SpatialCollection([$bridge], 4326); + $owner = new SpatialCollection([$point]); + $this->expectException(InvalidValueException::class); + + try { + $collection->addElement($nested); + } finally { + static::assertSame([$point], $collection->getElements()); + static::assertSame([$point], $owner->getElements()); + static::assertSame($bridge, $nested->getElement(0)); + static::assertSame([$collection], $bridge->getElements()); + static::assertSame(2154, $collection->getSrid()); + static::assertSame(4326, $point->getSrid()); + static::assertSame(SpatialModelEnum::GEOMETRY, $point->getFamily()); + } + } + + /** A member referencing the receiver cannot close a membership cycle. */ + public function testIndirectInsert(): void + { + $point = new Point(1, null, 4326); + $collection = new SpatialCollection([$point], 2154); + $bridge = new SpatialCollection([$collection], 3857); + $nested = new SpatialCollection([$bridge], 4326); + $owner = new SpatialCollection([$point]); + $this->expectException(InvalidValueException::class); + + try { + $collection->insertElement(1, $nested); + } finally { + static::assertSame([$point], $collection->getElements()); + static::assertSame([$point], $owner->getElements()); + static::assertSame($bridge, $nested->getElement(0)); + static::assertSame([$collection], $bridge->getElements()); + static::assertSame(2154, $collection->getSrid()); + static::assertSame(4326, $point->getSrid()); + static::assertSame(SpatialModelEnum::GEOMETRY, $point->getFamily()); + } + } + + /** A member referencing the receiver cannot close a membership cycle. */ + public function testIndirectReplace(): void + { + $point = new Point(1, null, 4326); + $collection = new SpatialCollection([$point], 2154); + $bridge = new SpatialCollection([$collection], 3857); + $nested = new SpatialCollection([$bridge], 4326); + $owner = new SpatialCollection([$point]); + $this->expectException(InvalidValueException::class); + + try { + $collection->setElement(0, $nested); + } finally { + static::assertSame([$point], $collection->getElements()); + static::assertSame([$point], $owner->getElements()); + static::assertSame($bridge, $nested->getElement(0)); + static::assertSame([$collection], $bridge->getElements()); + static::assertSame(2154, $collection->getSrid()); + static::assertSame(4326, $point->getSrid()); + static::assertSame(SpatialModelEnum::GEOMETRY, $point->getFamily()); + } + } + + /** A member referencing the receiver cannot close a membership cycle. */ + public function testIndirectReplaceAll(): void + { + $point = new Point(1, null, 4326); + $collection = new SpatialCollection([$point], 2154); + $bridge = new SpatialCollection([$collection], 3857); + $nested = new SpatialCollection([$bridge], 4326); + $owner = new SpatialCollection([$point]); + $this->expectException(InvalidValueException::class); + + try { + $collection->setElements([$point, $nested]); + } finally { + static::assertSame([$point], $collection->getElements()); + static::assertSame([$point], $owner->getElements()); + static::assertSame($bridge, $nested->getElement(0)); + static::assertSame([$collection], $bridge->getElements()); + static::assertSame(2154, $collection->getSrid()); + static::assertSame(4326, $point->getSrid()); + static::assertSame(SpatialModelEnum::GEOMETRY, $point->getFamily()); + } + } + + /** Repeated members and shared descendant diamonds are valid membership graphs. */ + public function testSharedAcyclicMembersRemainSupported(): void + { + $point = new Point(1, null, 4326); + $shared = new SpatialCollection([$point], 4326); + $left = new SpatialCollection([$shared], 4326); + $right = new SpatialCollection([$shared], 4326); + $collection = new SpatialCollection([$left, $right, $shared, $shared], 4326); + static::assertSame($collection, $collection->addElement($shared)->insertElement(0, $shared)->setElement(1, $right)); + static::assertSame($collection, $collection->setElements([$left, $right, $shared, $shared])); + static::assertSame([$left, $right, $shared, $shared], $collection->getElements()); + static::assertSame($shared, $left->getElement(0)); + static::assertSame($shared, $right->getElement(0)); + static::assertSame($collection, $collection->setSrid(2154)->setFamily(SpatialModelEnum::GEOGRAPHY)->setSpatialReference(SpatialReference::epsg(2154))); + static::assertSame(2154, $point->getSrid()); + static::assertSame('EPSG', $point->getSpatialReference()->authority); + static::assertSame(SpatialModelEnum::GEOGRAPHY, $point->getFamily()); + static::assertSame([[[[1, null]]], [[[1, null]]], [[1, null]], [[1, null]]], $collection->toArray()); + static::assertSame($point, $shared->getElement(0)); + } +}