Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/Database/Adapter/MariaDB.php
Original file line number Diff line number Diff line change
Expand Up @@ -1045,6 +1045,11 @@ protected function decodePolygon(string $wkb): array

private const string TIMEOUT_SETTING = 'timeout';

/**
* ER_GIS_INVALID_DATA. MariaDB reads most invalid geometries as NULL instead.
*/
protected const array INVALID_GEOMETRY_ERRORS = [3037];

/** The session timeout last set, in milliseconds. */
private int $appliedTimeout = 0;

Expand Down Expand Up @@ -1609,6 +1614,10 @@ protected function processException(PDOException $e): Exception
return new LimitException('Value is out of range', $e->getCode(), $e);
}

if (isset($e->errorInfo[1]) && \in_array($e->errorInfo[1], static::INVALID_GEOMETRY_ERRORS, true)) {
return new QueryException('Invalid spatial value', $e->getCode(), $e);
}

if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1049) {
return new NotFoundException('Database not found', $e->getCode(), $e);
}
Expand Down
5 changes: 5 additions & 0 deletions src/Database/Adapter/MySQL.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ protected function processException(PDOException $e): Exception
return parent::processException($e);
}

/**
* ER_GIS_INVALID_DATA, ER_LONGITUDE_OUT_OF_RANGE and ER_LATITUDE_OUT_OF_RANGE.
*/
protected const array INVALID_GEOMETRY_ERRORS = [3037, 3616, 3617];

#[\Override]
protected function dialectBuilder(): SQLBuilder&Scoping
{
Expand Down
59 changes: 59 additions & 0 deletions src/Database/Validator/Geometry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

namespace Utopia\Database\Validator;

use Utopia\Query\Schema\ColumnType;
use Utopia\Validator;

/**
* Validates the geometry a spatial query compares against, by the shape the builders read it as: `[x, y]` and
* `[[x, y]]` are a point, a deeper list of points a linestring, and a list of rings a polygon. Each is held to the
* rules a stored value of its type is.
*/
class Geometry extends Validator
{
private string $message = 'Geometry must be a point [x, y], a list of points or a list of rings of points';

#[\Override]
public function getDescription(): string
{
return $this->message;
}

#[\Override]
public function isValid(mixed $value): bool
{
if (! \is_array($value) || $value === [] || ! \array_is_list($value)) {
return false;
}

$first = $value[0];
[$type, $geometry] = match (true) {
! \is_array($first) => [ColumnType::Point, $value],
\is_array($first[0] ?? null) => [ColumnType::Polygon, $value],
\count($value) === 1 => [ColumnType::Point, $first],
default => [ColumnType::Linestring, $value],
};

$spatial = new Spatial($type->value);
if (! $spatial->isValid($geometry)) {
$this->message = $spatial->getDescription();

return false;
}

return true;
}

#[\Override]
public function isArray(): bool
{
return false;
}

#[\Override]
public function getType(): string
{
return self::TYPE_ARRAY;
}
}
26 changes: 24 additions & 2 deletions src/Database/Validator/Query/Filter.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
use Utopia\Database\Query;
use Utopia\Database\Validator\BigInt;
use Utopia\Database\Validator\Datetime as DatetimeValidator;
use Utopia\Database\Validator\Geometry;
use Utopia\Database\Validator\ObjectPath;
use Utopia\Database\Validator\Query\Joined\Attributes;
use Utopia\Database\Validator\Query\Joined\Collection;
use Utopia\Database\Validator\Sequence;
use Utopia\Database\Validator\Spatial;
use Utopia\Query\Method;
use Utopia\Query\Query as BaseQuery;
use Utopia\Query\Schema\ColumnType;
Expand All @@ -29,6 +31,8 @@ class Filter extends Base
*/
protected array $schema = [];

private const array DISTANCE_METHODS = [Method::DistanceEqual, Method::DistanceNotEqual, Method::DistanceGreaterThan, Method::DistanceLessThan];

/**
* @param array<Attribute|Document> $attributes
*/
Expand Down Expand Up @@ -297,6 +301,8 @@ protected function isValidAttributeAndValues(string $attribute, array $values, M
}
}

$geometry = new Geometry();

foreach ($values as $value) {
switch ($attributeType) {
case ColumnType::Object:
Expand All @@ -323,6 +329,12 @@ protected function isValidAttributeAndValues(string $attribute, array $values, M
return false;
}

if (! \in_array($method, self::DISTANCE_METHODS, true) && ! $geometry->isValid($value)) {
$this->message = 'Invalid spatial data for attribute "'.$attribute.'": '.$geometry->getDescription();

return false;
}

continue 2;

case ColumnType::Vector:
Expand Down Expand Up @@ -437,7 +449,8 @@ protected function isEmpty(array $values): bool
}

/**
* A distance query holds one `[geometry, distance, meters]` tuple: a finite distance and whether it is in meters.
* A distance query holds one `[geometry, distance, meters]` tuple: the geometry as coordinates or WKT, a finite
* distance and whether it is in meters.
*
* @param array<mixed> $values
*/
Expand All @@ -450,7 +463,16 @@ private function isValidDistance(array $values): bool
return false;
}

[, $distance, $meters] = $tuple;
[$geometry, $distance, $meters] = $tuple;

$validator = \is_string($geometry)
? new Spatial((Spatial::parseWkt($geometry)[0] ?? ColumnType::Point)->value)
: new Geometry();
if (! $validator->isValid($geometry)) {
$this->message = 'Distance query requires a geometry as coordinates or WKT: '.$validator->getDescription();

return false;
}

if (! \is_numeric($distance) || ! \is_finite((float) $distance)) {
$this->message = 'Distance query requires a finite distance';
Expand Down
146 changes: 134 additions & 12 deletions src/Database/Validator/Spatial.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public function __construct(string $spatialType)
*/
protected function validatePoint(array $value): bool
{
if (count($value) !== 2) {
if (count($value) !== 2 || ! array_is_list($value)) {
$this->message = 'Point must be an array of two numeric values [x, y]';

return false;
Expand Down Expand Up @@ -55,7 +55,7 @@ protected function validateLineString(array $value): bool
}

foreach ($value as $pointIndex => $point) {
if (! is_array($point) || count($point) !== 2) {
if (! is_array($point) || count($point) !== 2 || ! array_is_list($point)) {
$this->message = 'Each point in LineString must be an array of two values [x, y]';

return false;
Expand Down Expand Up @@ -91,7 +91,7 @@ protected function validatePolygon(array $value): bool
}

$isSingleRing = isset($value[0]) && is_array($value[0]) &&
count($value[0]) === 2 &&
count($value[0]) === 2 && array_is_list($value[0]) &&
is_numeric($value[0][0]) &&
is_numeric($value[0][1]);

Expand All @@ -100,7 +100,7 @@ protected function validatePolygon(array $value): bool
}

foreach ($value as $ringIndex => $ring) {
if (! is_array($ring) || empty($ring)) {
if (! is_array($ring) || empty($ring) || ! array_is_list($ring)) {
$this->message = "Ring #{$ringIndex} must be an array of points";

return false;
Expand All @@ -113,7 +113,7 @@ protected function validatePolygon(array $value): bool
}

foreach ($ring as $pointIndex => $point) {
if (! is_array($point) || count($point) !== 2) {
if (! is_array($point) || count($point) !== 2 || ! array_is_list($point)) {
$this->message = "Point #{$pointIndex} in ring #{$ringIndex} must be an array of two values [x, y]";

return false;
Expand Down Expand Up @@ -143,16 +143,120 @@ protected function validatePolygon(array $value): bool
}

/**
* Check if a value is a valid WKT (Well-Known Text) string.
*
* @param string $value The string to check
* @return bool
* Whether a value is well-formed WKT (Well-Known Text) of a point, linestring or polygon. Its coordinates are not
* checked; isValid() checks a stored value's.
*/
public static function isWktString(string $value): bool
{
$value = trim($value);
return self::parseWkt($value) !== null;
}

/**
* The type and coordinates WKT names, as a stored value of that type holds them: `[x, y]` for a point, a list of
* points for a linestring and a list of rings for a polygon. Null for anything that is not well-formed WKT of a
* point, linestring or polygon.
*
* @return array{ColumnType, array<mixed>}|null
*/
public static function parseWkt(string $value): ?array
{
$value = \trim($value);
$open = \strpos($value, '(');
if ($open === false || ! \str_ends_with($value, ')')) {
return null;
}

$type = match (\strtoupper(\trim(\substr($value, 0, $open)))) {
'POINT' => ColumnType::Point,
'LINESTRING' => ColumnType::Linestring,
'POLYGON' => ColumnType::Polygon,
default => null,
};
if ($type === null) {
return null;
}

$body = \substr($value, $open + 1, -1);
$coordinates = match ($type) {
ColumnType::Point => self::parseWktPoint($body),
ColumnType::Linestring => self::parseWktPoints($body),
default => self::parseWktRings($body),
};

return $coordinates === null ? null : [$type, $coordinates];
}

/**
* @return list<float>|null
*/
private static function parseWktPoint(string $text): ?array
{
$tokens = \array_values(\array_filter(
\explode(' ', \str_replace(["\t", "\n", "\r"], ' ', $text)),
static fn (string $token): bool => $token !== '',
));

if (\count($tokens) !== 2 || ! \is_numeric($tokens[0]) || ! \is_numeric($tokens[1])) {
return null;
}

return [(float) $tokens[0], (float) $tokens[1]];
}

/**
* @return list<list<float>>|null
*/
private static function parseWktPoints(string $text): ?array
{
$points = [];
foreach (\explode(',', $text) as $point) {
$parsed = self::parseWktPoint($point);
if ($parsed === null) {
return null;
}
$points[] = $parsed;
}

return (bool) preg_match('/^(POINT|LINESTRING|POLYGON)\s*\(/i', $value);
return $points;
}

/**
* @return list<list<list<float>>>|null
*/
private static function parseWktRings(string $text): ?array
{
$rings = [];
$rest = \trim($text);
while ($rest !== '') {
if (! \str_starts_with($rest, '(')) {
return null;
}

$close = \strpos($rest, ')');
if ($close === false) {
return null;
}

$ring = self::parseWktPoints(\substr($rest, 1, $close - 1));
if ($ring === null) {
return null;
}
$rings[] = $ring;

$rest = \trim(\substr($rest, $close + 1));
if ($rest === '') {
break;
}
if (! \str_starts_with($rest, ',')) {
return null;
}
$rest = \trim(\substr($rest, 1));
if ($rest === '') {
return null;
}
}

return $rings === [] ? null : $rings;
}

#[\Override]
Expand Down Expand Up @@ -192,7 +296,19 @@ public function isValid(mixed $value): bool
}

if (is_string($value)) {
return self::isWktString($value);
$wkt = self::parseWkt($value);
if ($wkt === null) {
$this->message = 'WKT must be a well-formed POINT, LINESTRING or POLYGON';

return false;
}

[$type, $value] = $wkt;
if ($type->value !== $this->spatialType) {
$this->message = 'WKT must be a '.\strtoupper($this->spatialType).', got a '.\strtoupper($type->value);

return false;
}
}

if (is_array($value)) {
Expand Down Expand Up @@ -221,6 +337,12 @@ public function isValid(mixed $value): bool

private function isValidCoordinate(int|float $x, int|float $y): bool
{
if (! \is_finite($x) || ! \is_finite($y)) {
$this->message = 'Coordinates must be finite numbers';

return false;
}

if ($x < -180 || $x > 180) {
$this->message = "Longitude (x) must be between -180 and 180, got {$x}";

Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/Adapter/Scopes/RelationshipTests.php
Original file line number Diff line number Diff line change
Expand Up @@ -3536,7 +3536,7 @@ public function testRelationshipSpatialQueries(): void
[-73.99, 40.76],
];
$restaurants = $database->find('restaurantsSpatial', [
Query::intersects('supplier.deliveryRoute', [$testLine]),
Query::intersects('supplier.deliveryRoute', $testLine),
]);
// Verify query executes (result count depends on DB spatial implementation)
$this->assertGreaterThanOrEqual(0, count($restaurants));
Expand All @@ -3547,7 +3547,7 @@ public function testRelationshipSpatialQueries(): void
[-73.95, 40.80],
];
$restaurants = $database->find('restaurantsSpatial', [
Query::crosses('supplier.deliveryRoute', [$crossingLine]),
Query::crosses('supplier.deliveryRoute', $crossingLine),
]);
// Result depends on actual geometry intersection

Expand Down
Loading
Loading