Skip to content

Commit

Permalink
Merge pull request #125 from clickbar/add-bbox-casters
Browse files Browse the repository at this point in the history
feat!(box): add castable to boxes & fromString
  • Loading branch information
ahawlitschek authored Jan 3, 2025
2 parents e1e1530 + 12db426 commit b50a220
Show file tree
Hide file tree
Showing 8 changed files with 236 additions and 16 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Removed support for Laravel 9.x
- Removed the `HasPostgisColumns` trait & `$postgisColumns` property.
- Removed `GeometryWKBCast`
- Refactored `BBoxCast` to be generic & internal, please use Box subclasses directly
- Removed automatic SRID transformation
- Removed st prefixed builder functions (e.g. `stSelect`, `stWhere`, ...)
- Removed `GeometryType` Enum

### Added

- Added `Castable` to all geometries to use them as casters, instead of the `GeometryWKBCast`
- Added `Castable` to all boxes to use them as casters, instead of the `BBoxCast`
- Added `Aliased` Expression class as wrapper for `AS` in query selects
- Added `withMagellanCasts()` as EloquentBuilder macro
- Added `AsGeometry` and `AsGeography` database expressions
- Added `fromString()` to `Box` classes to create a box from a string

### Improved

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,13 @@ $table->magellanPoint('location', 4326);

## Preparing the Model

In order to properly integrate everything with the model you only need to add the appropriate cast (each Geometry can be used):
In order to properly integrate everything with the model you only need to add the appropriate cast (each Geometry and Box can be used):

```php
protected $casts = [
/** ... */
'location' => Point::class,
'bounds' => Box2D::class,
];
```

Expand Down
32 changes: 20 additions & 12 deletions src/Cast/BBoxCast.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,30 @@

namespace Clickbar\Magellan\Cast;

use Clickbar\Magellan\Data\Boxes\Box;
use Clickbar\Magellan\Data\Boxes\Box2D;
use Clickbar\Magellan\Data\Boxes\Box3D;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;

/**
* @implements CastsAttributes<Box2D|Box3D,Box2D|Box3D>
* @internal Use the specific Box (sub)class as caster in the model e.g. Box2D::class or Box3D::class
*
* @template T of Box
*
* @implements CastsAttributes<T,T>
*/
class BBoxCast implements CastsAttributes
{
/**
* @param class-string<Box> $boxClass
*/
public function __construct(
protected string $boxClass,
) {
}

/**
* Cast the given value.
*
Expand All @@ -24,20 +37,15 @@ public function get($model, string $key, mixed $value, array $attributes)
return null;
}

$argument = Str::between($value, '(', ')');

$floats = Str::of($argument)->split('/[\s,]+/')->map(fn ($item) => floatval($item));

if ($floats->count() !== 4 && $floats->count() !== 6) {
return null;
}
if ($this->boxClass === Box::class) {
if (Str::contains($value, 'BOX3D', true)) {
return Box3D::fromString($value);
}

$is3d = Str::startsWith($value, 'BOX3D');
if ($is3d) {
return Box3D::make($floats[0], $floats[1], $floats[2], $floats[3], $floats[4], $floats[5]);
return Box2D::fromString($value);
}

return Box2D::make($floats[0], $floats[1], $floats[2], $floats[3]);
return $this->boxClass::fromString($value);
}

/**
Expand Down
14 changes: 13 additions & 1 deletion src/Data/Boxes/Box.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,21 @@

namespace Clickbar\Magellan\Data\Boxes;

use Clickbar\Magellan\Cast\BBoxCast;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Query\Expression as ExpressionContract;

abstract class Box implements ExpressionContract
abstract class Box implements Castable, ExpressionContract
{
abstract public static function fromString(string $box): self;

abstract public function toString(): string;

/**
* @return BBoxCast<Box>
*/
public static function castUsing(array $arguments): BBoxCast
{
return new BBoxCast(static::class);
}
}
16 changes: 16 additions & 0 deletions src/Data/Boxes/Box2D.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,20 @@ public function getValue(Grammar $grammar): string
{
return $grammar->quoteString($this->toString()).'::box2d';
}

public static function fromString(string $box): self
{
preg_match('/^BOX\(([-+]?\d+(?:.\d+)?)\s([-+]?\d+(?:.\d+)?),([-+]?\d+(?:.\d+)?)\s([-+]?\d+(?:.\d+)?)\)$/i', $box, $coordinates);

if (count($coordinates) !== 5) {
throw new \InvalidArgumentException('Invalid format for Box2D. Expected BOX(x y,x y), got '.$box);
}

return new self(
floatval($coordinates[1]),
floatval($coordinates[2]),
floatval($coordinates[3]),
floatval($coordinates[4])
);
}
}
18 changes: 18 additions & 0 deletions src/Data/Boxes/Box3D.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,22 @@ public function getValue(Grammar $grammar): string
{
return $grammar->quoteString($this->toString()).'::box3d';
}

public static function fromString(string $box): self
{
preg_match('/^BOX3D\(([-+]?\d+(?:.\d+)?)\s([-+]?\d+(?:.\d+)?)\s([-+]?\d+(?:.\d+)?),([-+]?\d+(?:.\d+)?)\s([-+]?\d+(?:.\d+)?)\s([-+]?\d+(?:.\d+)?)\)$/i', $box, $coordinates);

if (count($coordinates) !== 7) {
throw new \InvalidArgumentException('Invalid format for Box3D. Expected BOX3D(x y z,x y z), got '.$box);
}

return new self(
floatval($coordinates[1]),
floatval($coordinates[2]),
floatval($coordinates[3]),
floatval($coordinates[4]),
floatval($coordinates[5]),
floatval($coordinates[6])
);
}
}
4 changes: 2 additions & 2 deletions src/Database/Builder/EloquentBuilderMacros.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

namespace Clickbar\Magellan\Database\Builder;

use Clickbar\Magellan\Cast\BBoxCast;
use Clickbar\Magellan\Data\Boxes\Box;
use Clickbar\Magellan\Data\Geometries\Geometry;
use Clickbar\Magellan\Database\Expressions\Aliased;
use Clickbar\Magellan\Database\MagellanExpressions\MagellanBaseExpression;
Expand Down Expand Up @@ -47,7 +47,7 @@ public function withMagellanCasts(): \Closure
}

if ($magellanExpression?->returnsBbox()) {
$this->withCasts([$as => BBoxCast::class]);
$this->withCasts([$as => Box::class]);
}

if ($magellanExpression?->returnsGeometry()) {
Expand Down
162 changes: 162 additions & 0 deletions tests/Models/SpatialQueriesTest.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<?php

use Clickbar\Magellan\Data\Boxes\Box;
use Clickbar\Magellan\Data\Boxes\Box2D;
use Clickbar\Magellan\Data\Boxes\Box3D;
use Clickbar\Magellan\Data\Geometries\LineString;
use Clickbar\Magellan\Data\Geometries\MultiPoint;
use Clickbar\Magellan\Data\Geometries\Point;
Expand Down Expand Up @@ -439,3 +442,162 @@ function ($subquery) {
expect($thrownException)->toBeInstanceOf(QueryException::class);
expect($thrownException->getMessage())->toContain('st_coorddim(geography) does not exist');
});

test('it can automatically cast to Box2D', function () {
Location::create([
'name' => 'Berlin',
'location' => Point::makeGeodetic(52.52, 13.405),
]);

$box = Location::query()
->select(ST::makeBox2D(Point::make(1, 2), Point::make(3, 4)))
->withMagellanCasts()
->first()
->st_makebox2d;

expect($box)->toBeInstanceOf(Box2D::class);
});

test('it can automatically cast to Box3D', function () {
Location::create([
'name' => 'Berlin',
'location' => Point::makeGeodetic(52.52, 13.405),
]);

$box = Location::query()
->select(ST::makeBox3D(Point::make(1, 2, 3), Point::make(3, 4, 5)))
->withMagellanCasts()
->first()
->st_3dmakebox;

expect($box)->toBeInstanceOf(Box3D::class);
});

test('it can cast using Box', function () {
Location::create([
'name' => 'Berlin',
'location' => Point::makeGeodetic(52.52, 13.405),
]);

$box = Location::query()
->select(ST::makeBox2D(Point::make(1, 2), Point::make(3, 4)))
->withCasts(['st_makebox2d' => Box::class])
->first()
->st_makebox2d;

expect($box)->toBeInstanceOf(Box2D::class);

$box3d = Location::query()
->select(ST::makeBox3D(Point::make(1, 2, 3), Point::make(3, 4, 5)))
->withCasts(['st_3dmakebox' => Box::class])
->first()
->st_3dmakebox;

expect($box3d)->toBeInstanceOf(Box3D::class);
});

test('it can cast using Box2D', function () {
Location::create([
'name' => 'Berlin',
'location' => Point::makeGeodetic(52.52, 13.405),
]);

$box = Location::query()
->select(ST::makeBox2D(Point::make(1, 2), Point::make(3, 4)))
->withCasts(['st_makebox2d' => Box2D::class])
->first()
->st_makebox2d;

expect($box)->toBeInstanceOf(Box2D::class);
});

test('it can cast using Box3D', function () {
Location::create([
'name' => 'Berlin',
'location' => Point::makeGeodetic(52.52, 13.405),
]);

$box3d = Location::query()
->select(ST::makeBox3D(Point::make(1, 2, 3), Point::make(3, 4, 5)))
->withCasts(['st_3dmakebox' => Box3D::class])
->first()
->st_3dmakebox;

expect($box3d)->toBeInstanceOf(Box3D::class);
});

test('it throws when using Box3D instead of Box2D cast', function () {
Location::create([
'name' => 'Berlin',
'location' => Point::makeGeodetic(52.52, 13.405),
]);

$thrownException = null;

try {
Location::query()
->select(ST::makeBox2D(Point::make(1, 2), Point::make(3, 4)))
->withCasts(['st_makebox2d' => Box3D::class])
->first()
->st_makebox2d;
} catch (InvalidArgumentException $exception) {
$thrownException = $exception;
}

expect($thrownException)->toBeInstanceOf(InvalidArgumentException::class);
expect($thrownException->getMessage())->toContain('Invalid format for Box3D. Expected BOX3D(');

});

test('it throws when using Box2D instead of Box3D cast', function () {
Location::create([
'name' => 'Berlin',
'location' => Point::makeGeodetic(52.52, 13.405),
]);

$thrownException = null;

try {
Location::query()
->select(ST::makeBox3D(Point::make(1, 2, 3), Point::make(3, 4, 5)))
->withCasts(['st_3dmakebox' => Box2D::class])
->first()
->st_3dmakebox;
} catch (InvalidArgumentException $exception) {
$thrownException = $exception;
}

expect($thrownException)->toBeInstanceOf(InvalidArgumentException::class);
expect($thrownException->getMessage())->toContain('Invalid format for Box2D. Expected BOX(');

});

test('Box2D can handle null values', function () {
Location::create([
'name' => 'Berlin',
'location' => Point::makeGeodetic(52.52, 13.405),
]);

$box = Location::query()
->selectRaw('null as st_makebox2d')
->withCasts(['st_makebox2d' => Box2D::class])
->first()
->st_makebox2d;

expect($box)->toBeNull();
});

test('Box3D can handle null values', function () {
Location::create([
'name' => 'Berlin',
'location' => Point::makeGeodetic(52.52, 13.405),
]);

$box3d = Location::query()
->selectRaw('null as st_3dmakebox')
->withCasts(['st_3dmakebox' => Box3D::class])
->first()
->st_3dmakebox;

expect($box3d)->toBeNull();
});

0 comments on commit b50a220

Please sign in to comment.