From 3a4edc029a807f430ae5a93485e130dd5f028311 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 2 Sep 2025 18:49:47 +0530 Subject: [PATCH 1/6] fixed structure exception not getting raised for spatial types --- src/Database/Database.php | 4 +- src/Database/Validator/Spatial.php | 129 ++++++++-------------- tests/e2e/Adapter/Scopes/SpatialTests.php | 99 +++++++++++++++++ 3 files changed, 145 insertions(+), 87 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index aa87daa0f..8f1d4ba5d 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -7133,7 +7133,9 @@ private function processRelationshipQueries( protected function encodeSpatialData(mixed $value, string $type): string { $validator = new Spatial($type); - $validator->isValid($value); + if (!$validator->isValid($value)) { + throw new StructureException($validator->getDescription()); + } switch ($type) { case self::VAR_POINT: diff --git a/src/Database/Validator/Spatial.php b/src/Database/Validator/Spatial.php index 30026dfe2..06a62e7ae 100644 --- a/src/Database/Validator/Spatial.php +++ b/src/Database/Validator/Spatial.php @@ -3,62 +3,31 @@ namespace Utopia\Database\Validator; use Utopia\Database\Database; -use Utopia\Database\Exception; use Utopia\Validator; class Spatial extends Validator { private string $spatialType; + protected string $message = ''; public function __construct(string $spatialType) { $this->spatialType = $spatialType; } - /** - * Validate spatial data according to its type - * - * @param mixed $value - * @param string $type - * @return bool - * @throws Exception - */ - public static function validate(mixed $value, string $type): bool - { - if (!is_array($value)) { - throw new Exception('Spatial data must be provided as an array'); - } - - switch ($type) { - case Database::VAR_POINT: - return self::validatePoint($value); - - case Database::VAR_LINESTRING: - return self::validateLineString($value); - - case Database::VAR_POLYGON: - return self::validatePolygon($value); - - default: - throw new Exception('Unknown spatial type: ' . $type); - } - } - /** * Validate POINT data - * - * @param array $value - * @return bool - * @throws Exception */ - protected static function validatePoint(array $value): bool + protected function validatePoint(array $value): bool { if (count($value) !== 2) { - throw new Exception('Point must be an array of two numeric values [x, y]'); + $this->message = 'Point must be an array of two numeric values [x, y]'; + return false; } if (!is_numeric($value[0]) || !is_numeric($value[1])) { - throw new Exception('Point coordinates must be numeric values'); + $this->message = 'Point coordinates must be numeric values'; + return false; } return true; @@ -66,24 +35,23 @@ protected static function validatePoint(array $value): bool /** * Validate LINESTRING data - * - * @param array $value - * @return bool - * @throws Exception */ - protected static function validateLineString(array $value): bool + protected function validateLineString(array $value): bool { if (count($value) < 2) { - throw new Exception('LineString must contain at least one point'); + $this->message = 'LineString must contain at least two points'; + return false; } foreach ($value as $point) { if (!is_array($point) || count($point) !== 2) { - throw new Exception('Each point in LineString must be an array of two values [x, y]'); + $this->message = 'Each point in LineString must be an array of two values [x, y]'; + return false; } if (!is_numeric($point[0]) || !is_numeric($point[1])) { - throw new Exception('Each point in LineString must have numeric coordinates'); + $this->message = 'Each point in LineString must have numeric coordinates'; + return false; } } @@ -92,36 +60,39 @@ protected static function validateLineString(array $value): bool /** * Validate POLYGON data - * - * @param array $value - * @return bool - * @throws Exception */ - protected static function validatePolygon(array $value): bool + protected function validatePolygon(array $value): bool { if (empty($value)) { - throw new Exception('Polygon must contain at least one ring'); + $this->message = 'Polygon must contain at least one ring'; + return false; } // Detect single-ring polygon: [[x, y], [x, y], ...] $isSingleRing = isset($value[0]) && is_array($value[0]) && - count($value[0]) === 2 && is_numeric($value[0][0]) && is_numeric($value[0][1]); + count($value[0]) === 2 && + is_numeric($value[0][0]) && + is_numeric($value[0][1]); if ($isSingleRing) { - $value = [$value]; // Wrap single ring into multi-ring format + $value = [$value]; // wrap single ring } foreach ($value as $ring) { if (!is_array($ring) || empty($ring)) { - throw new Exception('Each ring in Polygon must be an array of points'); + $this->message = 'Each ring in Polygon must be an array of points'; + return false; } foreach ($ring as $point) { if (!is_array($point) || count($point) !== 2) { - throw new Exception('Each point in Polygon ring must be an array of two values [x, y]'); + $this->message = 'Each point in Polygon ring must be an array of two values [x, y]'; + return false; } + if (!is_numeric($point[0]) || !is_numeric($point[1])) { - throw new Exception('Each point in Polygon ring must have numeric coordinates'); + $this->message = 'Each point in Polygon ring must have numeric coordinates'; + return false; } } } @@ -131,9 +102,6 @@ protected static function validatePolygon(array $value): bool /** * Check if a value is valid WKT string - * - * @param string $value - * @return bool */ public static function isWKTString(string $value): bool { @@ -141,41 +109,23 @@ public static function isWKTString(string $value): bool return (bool) preg_match('/^(POINT|LINESTRING|POLYGON)\s*\(/i', $value); } - /** - * Get validator description - * - * @return string - */ public function getDescription(): string { - return 'Value must be a valid ' . $this->spatialType . ' format (array or WKT string)'; + return 'Value must be a valid ' . $this->spatialType . ": {$this->message}"; } - /** - * Is array - * - * @return bool - */ public function isArray(): bool { return false; } - /** - * Get Type - * - * @return string - */ public function getType(): string { return 'spatial'; } /** - * Is valid - * - * @param mixed $value - * @return bool + * Main validation entrypoint */ public function isValid($value): bool { @@ -184,20 +134,27 @@ public function isValid($value): bool } if (is_string($value)) { - // Check if it's a valid WKT string return self::isWKTString($value); } if (is_array($value)) { - // Validate the array format according to the specific spatial type - try { - self::validate($value, $this->spatialType); - return true; - } catch (\Exception $e) { - return false; + switch ($this->spatialType) { + case Database::VAR_POINT: + return $this->validatePoint($value); + + case Database::VAR_LINESTRING: + return $this->validateLineString($value); + + case Database::VAR_POLYGON: + return $this->validatePolygon($value); + + default: + $this->message = 'Unknown spatial type: ' . $this->spatialType; + return false; } } + $this->message = 'Spatial value must be array or WKT string'; return false; } } diff --git a/tests/e2e/Adapter/Scopes/SpatialTests.php b/tests/e2e/Adapter/Scopes/SpatialTests.php index 63f1b3c49..4af2afa62 100644 --- a/tests/e2e/Adapter/Scopes/SpatialTests.php +++ b/tests/e2e/Adapter/Scopes/SpatialTests.php @@ -5,6 +5,7 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception; +use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; @@ -1552,6 +1553,19 @@ public function testSpatialBulkOperation(): void $updateResults[] = $doc; }); + // should fail due to invalid structure + try { + $database->updateDocuments($collectionName, new Document([ + 'name' => 'Updated Location', + 'location' => [15.0, 25.0], + 'area' => [15.0, 25.0] // invalid polygon + ])); + $this->fail("fail to throw structure exception for the invalid spatial type"); + } catch (\Throwable $th) { + $this->assertInstanceOf(StructureException::class, $th); + + } + $this->assertGreaterThan(0, $updateCount); // Verify updated documents @@ -1969,4 +1983,89 @@ public function testSpatialAttributeDefaults(): void $database->deleteCollection($collectionName); } } + + public function testInvalidSpatialTypes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->markTestSkipped('Adapter does not support spatial attributes'); + } + + $collectionName = 'test_invalid_spatial_types'; + + $attributes = [ + new Document([ + '$id' => ID::custom('pointAttr'), + 'type' => Database::VAR_POINT, + 'size' => 0, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => ID::custom('lineAttr'), + 'type' => Database::VAR_LINESTRING, + 'size' => 0, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => ID::custom('polyAttr'), + 'type' => Database::VAR_POLYGON, + 'size' => 0, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]) + ]; + + $database->createCollection($collectionName, $attributes); + + // ❌ Invalid Point (must be [x, y]) + try { + $database->createDocument($collectionName, new Document([ + 'pointAttr' => [10.0], // only 1 coordinate + ])); + $this->fail("Expected StructureException for invalid point"); + } catch (\Throwable $th) { + $this->assertInstanceOf(StructureException::class, $th); + } + + // ❌ Invalid LineString (must be [[x,y],[x,y],...], at least 2 points) + try { + $database->createDocument($collectionName, new Document([ + 'lineAttr' => [[10.0, 20.0]], // only one point + ])); + $this->fail("Expected StructureException for invalid line"); + } catch (\Throwable $th) { + $this->assertInstanceOf(StructureException::class, $th); + } + + try { + $database->createDocument($collectionName, new Document([ + 'lineAttr' => [10.0, 20.0], // not an array of arrays + ])); + $this->fail("Expected StructureException for invalid line structure"); + } catch (\Throwable $th) { + $this->assertInstanceOf(StructureException::class, $th); + } + + try { + $database->createDocument($collectionName, new Document([ + 'polyAttr' => [10.0, 20.0] // not an array of arrays + ])); + $this->fail("Expected StructureException for invalid polygon structure"); + } catch (\Throwable $th) { + $this->assertInstanceOf(StructureException::class, $th); + } + + // Cleanup + $database->deleteCollection($collectionName); + } + } From 6746472eac9698137686b28bed3d0c28e1f8d568 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 2 Sep 2025 19:00:42 +0530 Subject: [PATCH 2/6] updated phpdocs --- src/Database/Validator/Spatial.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Database/Validator/Spatial.php b/src/Database/Validator/Spatial.php index 06a62e7ae..9843fe38c 100644 --- a/src/Database/Validator/Spatial.php +++ b/src/Database/Validator/Spatial.php @@ -17,6 +17,9 @@ public function __construct(string $spatialType) /** * Validate POINT data + * + * @param array $value + * @return bool */ protected function validatePoint(array $value): bool { @@ -25,16 +28,14 @@ protected function validatePoint(array $value): bool return false; } - if (!is_numeric($value[0]) || !is_numeric($value[1])) { - $this->message = 'Point coordinates must be numeric values'; - return false; - } - return true; } /** * Validate LINESTRING data + * + * @param array $value + * @return bool */ protected function validateLineString(array $value): bool { @@ -60,6 +61,9 @@ protected function validateLineString(array $value): bool /** * Validate POLYGON data + * + * @param array $value + * @return bool */ protected function validatePolygon(array $value): bool { From 21770b5c14a59a5eef3e5472a67c96b0e4fcf331 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 2 Sep 2025 20:41:09 +0530 Subject: [PATCH 3/6] Implement distance spatial queries and validation for spatial attributes --- src/Database/Adapter/MariaDB.php | 61 ++++++++++----- src/Database/Adapter/MySQL.php | 31 ++++++++ src/Database/Adapter/Postgres.php | 86 +++++++++++++-------- src/Database/Database.php | 3 + src/Database/Query.php | 20 +++-- src/Database/Validator/Query/Filter.php | 2 +- src/Database/Validator/Spatial.php | 28 +++++-- tests/e2e/Adapter/Scopes/SpatialTests.php | 94 ++++++++++++++++++++++- tests/unit/Validator/SpatialTest.php | 84 ++++++++++++++++++++ 9 files changed, 339 insertions(+), 70 deletions(-) create mode 100644 tests/unit/Validator/SpatialTest.php diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 0e1cb09b4..c7a989f2e 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1352,6 +1352,47 @@ public function deleteDocument(string $collection, string $id): bool return $deleted; } + /** + * Handle distance spatial queries + * + * @param Query $query + * @param array $binds + * @param string $attribute + * @param string $alias + * @param string $placeholder + * @return string + */ + protected function handleDistanceSpatialQueries(Query $query, array &$binds, string $attribute, string $alias, string $placeholder): string + { + $distanceParams = $query->getValues()[0]; + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); + $binds[":{$placeholder}_1"] = $distanceParams[1]; + + $meters = isset($distanceParams[2]) && $distanceParams[2] === true; + + switch ($query->getMethod()) { + case Query::TYPE_DISTANCE_EQUAL: + $operator = '='; + break; + case Query::TYPE_DISTANCE_NOT_EQUAL: + $operator = '!='; + break; + case Query::TYPE_DISTANCE_GREATER_THAN: + $operator = '>'; + break; + case Query::TYPE_DISTANCE_LESS_THAN: + $operator = '<'; + break; + default: + throw new DatabaseException('Unknown spatial query method: ' . $query->getMethod()); + } + + if ($meters) { + return "ST_DISTANCE_SPHERE({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0), 6371000) {$operator} :{$placeholder}_1"; + } + return "ST_Distance({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0)) {$operator} :{$placeholder}_1"; + } + /** * Handle spatial queries * @@ -1374,28 +1415,10 @@ protected function handleSpatialQueries(Query $query, array &$binds, string $att return "NOT ST_Crosses({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; case Query::TYPE_DISTANCE_EQUAL: - $distanceParams = $query->getValues()[0]; - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); - $binds[":{$placeholder}_1"] = $distanceParams[1]; - return "ST_Distance({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0)) = :{$placeholder}_1"; - case Query::TYPE_DISTANCE_NOT_EQUAL: - $distanceParams = $query->getValues()[0]; - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); - $binds[":{$placeholder}_1"] = $distanceParams[1]; - return "ST_Distance({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0)) != :{$placeholder}_1"; - case Query::TYPE_DISTANCE_GREATER_THAN: - $distanceParams = $query->getValues()[0]; - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); - $binds[":{$placeholder}_1"] = $distanceParams[1]; - return "ST_Distance({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0)) > :{$placeholder}_1"; - case Query::TYPE_DISTANCE_LESS_THAN: - $distanceParams = $query->getValues()[0]; - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); - $binds[":{$placeholder}_1"] = $distanceParams[1]; - return "ST_Distance({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0)) < :{$placeholder}_1"; + return $this->handleDistanceSpatialQueries($query, $binds, $attribute, $alias, $placeholder); case Query::TYPE_INTERSECTS: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); diff --git a/src/Database/Adapter/MySQL.php b/src/Database/Adapter/MySQL.php index be0cd79d3..84b3cb9e9 100644 --- a/src/Database/Adapter/MySQL.php +++ b/src/Database/Adapter/MySQL.php @@ -7,6 +7,7 @@ use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Dependency as DependencyException; use Utopia\Database\Exception\Timeout as TimeoutException; +use Utopia\Database\Query; class MySQL extends MariaDB { @@ -78,6 +79,36 @@ public function getSizeOfCollectionOnDisk(string $collection): int return $size; } + protected function handleDistanceSpatialQueries(Query $query, array &$binds, string $attribute, string $alias, string $placeholder): string + { + $distanceParams = $query->getValues()[0]; + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); + $binds[":{$placeholder}_1"] = $distanceParams[1]; + + $useMeters = isset($distanceParams[2]) && $distanceParams[2] === true; + + switch ($query->getMethod()) { + case Query::TYPE_DISTANCE_EQUAL: + $operator = '='; + break; + case Query::TYPE_DISTANCE_NOT_EQUAL: + $operator = '!='; + break; + case Query::TYPE_DISTANCE_GREATER_THAN: + $operator = '>'; + break; + case Query::TYPE_DISTANCE_LESS_THAN: + $operator = '<'; + break; + default: + throw new DatabaseException('Unknown spatial query method: ' . $query->getMethod()); + } + + $unit = $useMeters ? ", 'meter'" : ''; + + return "ST_Distance({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0){$unit}) {$operator} :{$placeholder}_1"; + } + public function getSupportForIndexArray(): bool { /** diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index ab44bead1..be462161b 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -1452,6 +1452,43 @@ public function getConnectionId(): string return $stmt->fetchColumn(); } + protected function handleDistanceSpatialQueries(Query $query, array &$binds, string $attribute, string $alias, string $placeholder): string + { + $distanceParams = $query->getValues()[0]; + $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); + $binds[":{$placeholder}_1"] = $distanceParams[1]; + + $meters = isset($distanceParams[2]) && $distanceParams[2] === true; + + switch ($query->getMethod()) { + case Query::TYPE_DISTANCE_EQUAL: + $operator = '='; + break; + case Query::TYPE_DISTANCE_NOT_EQUAL: + $operator = '!='; + break; + case Query::TYPE_DISTANCE_GREATER_THAN: + $operator = '>'; + break; + case Query::TYPE_DISTANCE_LESS_THAN: + $operator = '<'; + break; + default: + throw new DatabaseException('Unknown spatial query method: ' . $query->getMethod()); + } + + if ($meters) { + // Transform both attribute and input geometry to 3857 (meters) for distance calculation + $attr = "ST_Transform({$alias}.{$attribute}, 3857)"; + $geom = "ST_Transform(ST_GeomFromText(:{$placeholder}_0, " . Database::SRID . "), 3857)"; + return "ST_Distance({$attr}, {$geom}) {$operator} :{$placeholder}_1"; + } + + // Without meters, use the original SRID (e.g., 4326) + return "ST_Distance({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0, " . Database::SRID . ")) {$operator} :{$placeholder}_1"; + } + + /** * Handle spatial queries * @@ -1474,60 +1511,41 @@ protected function handleSpatialQueries(Query $query, array &$binds, string $att return "NOT ST_Crosses({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; case Query::TYPE_DISTANCE_EQUAL: - $distanceParams = $query->getValues()[0]; - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); - $binds[":{$placeholder}_1"] = $distanceParams[1]; - return "ST_DWithin({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0), :{$placeholder}_1)"; - case Query::TYPE_DISTANCE_NOT_EQUAL: - $distanceParams = $query->getValues()[0]; - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); - $binds[":{$placeholder}_1"] = $distanceParams[1]; - return "NOT ST_DWithin({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0), :{$placeholder}_1)"; - case Query::TYPE_DISTANCE_GREATER_THAN: - $distanceParams = $query->getValues()[0]; - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); - $binds[":{$placeholder}_1"] = $distanceParams[1]; - return "ST_Distance({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0)) > :{$placeholder}_1"; - case Query::TYPE_DISTANCE_LESS_THAN: - $distanceParams = $query->getValues()[0]; - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); - $binds[":{$placeholder}_1"] = $distanceParams[1]; - return "ST_Distance({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0)) < :{$placeholder}_1"; - + return $this->handleDistanceSpatialQueries($query, $binds, $attribute, $alias, $placeholder); case Query::TYPE_EQUAL: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Equals({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + return "ST_Equals({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0, " . Database::SRID . "))"; case Query::TYPE_NOT_EQUAL: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Equals({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + return "NOT ST_Equals({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0, " . Database::SRID . "))"; case Query::TYPE_INTERSECTS: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Intersects({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + return "ST_Intersects({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0, " . Database::SRID . "))"; case Query::TYPE_NOT_INTERSECTS: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Intersects({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + return "NOT ST_Intersects({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0, " . Database::SRID . "))"; case Query::TYPE_OVERLAPS: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Overlaps({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + return "ST_Overlaps({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0, " . Database::SRID . "))"; case Query::TYPE_NOT_OVERLAPS: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Overlaps({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + return "NOT ST_Overlaps({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0, " . Database::SRID . "))"; case Query::TYPE_TOUCHES: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Touches({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + return "ST_Touches({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0, " . Database::SRID . "))"; case Query::TYPE_NOT_TOUCHES: $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Touches({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + return "NOT ST_Touches({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0, " . Database::SRID . "))"; case Query::TYPE_CONTAINS: case Query::TYPE_NOT_CONTAINS: @@ -1536,8 +1554,8 @@ protected function handleSpatialQueries(Query $query, array &$binds, string $att $isNot = $query->getMethod() === Query::TYPE_NOT_CONTAINS; $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); return $isNot - ? "NOT ST_Covers({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))" - : "ST_Covers({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0))"; + ? "NOT ST_Covers({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0, " . Database::SRID . "))" + : "ST_Covers({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0, " . Database::SRID . "))"; default: throw new DatabaseException('Unknown spatial query method: ' . $query->getMethod()); @@ -1716,15 +1734,15 @@ protected function getSQLType(string $type, int $size, bool $signed = true, bool case Database::VAR_DATETIME: return 'TIMESTAMP(3)'; - + // in all other DB engines, 4326 is the default SRID case Database::VAR_POINT: - return 'GEOMETRY(POINT)'; + return 'GEOMETRY(POINT,' . Database::SRID . ')'; case Database::VAR_LINESTRING: - return 'GEOMETRY(LINESTRING)'; + return 'GEOMETRY(LINESTRING,' . Database::SRID . ')'; case Database::VAR_POLYGON: - return 'GEOMETRY(POLYGON)'; + return 'GEOMETRY(POLYGON,' . Database::SRID . ')'; default: throw new DatabaseException('Unknown Type: ' . $type . '. Must be one of ' . Database::VAR_STRING . ', ' . Database::VAR_INTEGER . ', ' . Database::VAR_FLOAT . ', ' . Database::VAR_BOOLEAN . ', ' . Database::VAR_DATETIME . ', ' . Database::VAR_RELATIONSHIP . ', ' . Database::VAR_POINT . ', ' . Database::VAR_LINESTRING . ', ' . Database::VAR_POLYGON); diff --git a/src/Database/Database.php b/src/Database/Database.php index 8f1d4ba5d..516949fe5 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -49,6 +49,9 @@ class Database public const BIG_INT_MAX = PHP_INT_MAX; public const DOUBLE_MAX = PHP_FLOAT_MAX; + // Global SRID for geographic coordinates (WGS84) + public const SRID = 4326; + // Relationship Types public const VAR_RELATIONSHIP = 'relationship'; diff --git a/src/Database/Query.php b/src/Database/Query.php index 24f40eece..adc2b524f 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -903,11 +903,12 @@ public function setOnArray(bool $bool): void * @param string $attribute * @param array $values * @param int|float $distance + * @param bool $meters * @return Query */ - public static function distanceEqual(string $attribute, array $values, int|float $distance): self + public static function distanceEqual(string $attribute, array $values, int|float $distance, bool $meters = false): self { - return new self(self::TYPE_DISTANCE_EQUAL, $attribute, [[$values,$distance]]); + return new self(self::TYPE_DISTANCE_EQUAL, $attribute, [[$values,$distance,$meters]]); } /** @@ -916,11 +917,12 @@ public static function distanceEqual(string $attribute, array $values, int|float * @param string $attribute * @param array $values * @param int|float $distance + * @param bool $meters * @return Query */ - public static function distanceNotEqual(string $attribute, array $values, int|float $distance): self + public static function distanceNotEqual(string $attribute, array $values, int|float $distance, $meters = false): self { - return new self(self::TYPE_DISTANCE_NOT_EQUAL, $attribute, [[$values,$distance]]); + return new self(self::TYPE_DISTANCE_NOT_EQUAL, $attribute, [[$values,$distance,$meters]]); } /** @@ -929,11 +931,12 @@ public static function distanceNotEqual(string $attribute, array $values, int|fl * @param string $attribute * @param array $values * @param int|float $distance + * @param bool $meters * @return Query */ - public static function distanceGreaterThan(string $attribute, array $values, int|float $distance): self + public static function distanceGreaterThan(string $attribute, array $values, int|float $distance, $meters = false): self { - return new self(self::TYPE_DISTANCE_GREATER_THAN, $attribute, [[$values,$distance]]); + return new self(self::TYPE_DISTANCE_GREATER_THAN, $attribute, [[$values,$distance, $meters]]); } /** @@ -942,11 +945,12 @@ public static function distanceGreaterThan(string $attribute, array $values, int * @param string $attribute * @param array $values * @param int|float $distance + * @param bool $meters * @return Query */ - public static function distanceLessThan(string $attribute, array $values, int|float $distance): self + public static function distanceLessThan(string $attribute, array $values, int|float $distance, bool $meters = false): self { - return new self(self::TYPE_DISTANCE_LESS_THAN, $attribute, [[$values,$distance]]); + return new self(self::TYPE_DISTANCE_LESS_THAN, $attribute, [[$values,$distance,$meters]]); } /** diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 9f331d871..9c60f551c 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -263,7 +263,7 @@ public function isValid($value): bool case Query::TYPE_DISTANCE_NOT_EQUAL: case Query::TYPE_DISTANCE_GREATER_THAN: case Query::TYPE_DISTANCE_LESS_THAN: - if (count($value->getValues()) !== 1 || !is_array($value->getValues()[0]) || count($value->getValues()[0]) !== 2) { + if (count($value->getValues()) !== 1 || !is_array($value->getValues()[0]) || count($value->getValues()[0]) !== 3) { $this->message = 'Distance query requires [[geometry, distance]] parameters'; return false; } diff --git a/src/Database/Validator/Spatial.php b/src/Database/Validator/Spatial.php index 9843fe38c..2470562cb 100644 --- a/src/Database/Validator/Spatial.php +++ b/src/Database/Validator/Spatial.php @@ -18,7 +18,7 @@ public function __construct(string $spatialType) /** * Validate POINT data * - * @param array $value + * @param array $value * @return bool */ protected function validatePoint(array $value): bool @@ -28,6 +28,11 @@ protected function validatePoint(array $value): bool return false; } + if (!is_numeric($value[0]) || !is_numeric($value[1])) { + $this->message = 'Point coordinates must be numeric values'; + return false; + } + return true; } @@ -82,23 +87,34 @@ protected function validatePolygon(array $value): bool $value = [$value]; // wrap single ring } - foreach ($value as $ring) { + foreach ($value as $ringIndex => $ring) { if (!is_array($ring) || empty($ring)) { - $this->message = 'Each ring in Polygon must be an array of points'; + $this->message = "Ring #{$ringIndex} must be an array of points"; + return false; + } + + if (count($ring) < 4) { + $this->message = "Ring #{$ringIndex} must contain at least 4 points to form a closed polygon"; return false; } - foreach ($ring as $point) { + foreach ($ring as $pointIndex => $point) { if (!is_array($point) || count($point) !== 2) { - $this->message = 'Each point in Polygon ring must be an array of two values [x, y]'; + $this->message = "Point #{$pointIndex} in ring #{$ringIndex} must be an array of two values [x, y]"; return false; } if (!is_numeric($point[0]) || !is_numeric($point[1])) { - $this->message = 'Each point in Polygon ring must have numeric coordinates'; + $this->message = "Coordinates of point #{$pointIndex} in ring #{$ringIndex} must be numeric"; return false; } } + + // Check that the ring is closed (first point == last point) + if ($ring[0] !== $ring[count($ring) - 1]) { + $this->message = "Ring #{$ringIndex} must be closed (first point must equal last point)"; + return false; + } } return true; diff --git a/tests/e2e/Adapter/Scopes/SpatialTests.php b/tests/e2e/Adapter/Scopes/SpatialTests.php index 4af2afa62..b1d3d9866 100644 --- a/tests/e2e/Adapter/Scopes/SpatialTests.php +++ b/tests/e2e/Adapter/Scopes/SpatialTests.php @@ -2026,7 +2026,7 @@ public function testInvalidSpatialTypes(): void $database->createCollection($collectionName, $attributes); - // ❌ Invalid Point (must be [x, y]) + // Invalid Point (must be [x, y]) try { $database->createDocument($collectionName, new Document([ 'pointAttr' => [10.0], // only 1 coordinate @@ -2036,7 +2036,7 @@ public function testInvalidSpatialTypes(): void $this->assertInstanceOf(StructureException::class, $th); } - // ❌ Invalid LineString (must be [[x,y],[x,y],...], at least 2 points) + // Invalid LineString (must be [[x,y],[x,y],...], at least 2 points) try { $database->createDocument($collectionName, new Document([ 'lineAttr' => [[10.0, 20.0]], // only one point @@ -2064,8 +2064,98 @@ public function testInvalidSpatialTypes(): void $this->assertInstanceOf(StructureException::class, $th); } + $invalidPolygons = [ + [[0,0],[1,1],[0,1]], + [[0,0],['a',1],[1,1],[0,0]], + [[0,0],[1,0],[1,1],[0,1]], + [], + [[0,0,5],[1,0,5],[1,1,5],[0,0,5]], + [ + [[0,0],[2,0],[2,2],[0,0]], // valid + [[0,0,1],[1,0,1],[1,1,1],[0,0,1]] // invalid 3D + ] + ]; + foreach ($invalidPolygons as $invalidPolygon) { + try { + $database->createDocument($collectionName, new Document([ + 'polyAttr' => $invalidPolygon + ])); + $this->fail("Expected StructureException for invalid polygon structure"); + } catch (\Throwable $th) { + $this->assertInstanceOf(StructureException::class, $th); + } + } // Cleanup $database->deleteCollection($collectionName); } + public function testSpatialDistanceInMeter(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->markTestSkipped('Adapter does not support spatial attributes'); + } + + $collectionName = 'spatial_distance_meters_'; + try { + $database->createCollection($collectionName); + $this->assertEquals(true, $database->createAttribute($collectionName, 'loc', Database::VAR_POINT, 0, true)); + $this->assertEquals(true, $database->createIndex($collectionName, 'idx_loc', Database::INDEX_SPATIAL, ['loc'])); + + // Two points roughly ~1000 meters apart by latitude delta (~0.009 deg ≈ 1km) + $p0 = $database->createDocument($collectionName, new Document([ + '$id' => 'p0', + 'loc' => [0.0000, 0.0000], + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + ])); + $p1 = $database->createDocument($collectionName, new Document([ + '$id' => 'p1', + 'loc' => [0.0090, 0.0000], + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())] + ])); + + $this->assertInstanceOf(Document::class, $p0); + $this->assertInstanceOf(Document::class, $p1); + + // distanceLessThan with meters=true: within 1500m should include both + $within1_5km = $database->find($collectionName, [ + Query::distanceLessThan('loc', [0.0000, 0.0000], 1500, true) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($within1_5km); + $this->assertCount(2, $within1_5km); + + // Within 500m should include only p0 (exact point) + $within500m = $database->find($collectionName, [ + Query::distanceLessThan('loc', [0.0000, 0.0000], 500, true) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($within500m); + $this->assertCount(1, $within500m); + $this->assertEquals('p0', $within500m[0]->getId()); + + // distanceGreaterThan 500m should include only p1 + $greater500m = $database->find($collectionName, [ + Query::distanceGreaterThan('loc', [0.0000, 0.0000], 500, true) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($greater500m); + $this->assertCount(1, $greater500m); + $this->assertEquals('p1', $greater500m[0]->getId()); + + // distanceEqual with 0m should return exact match p0 + $equalZero = $database->find($collectionName, [ + Query::distanceEqual('loc', [0.0000, 0.0000], 0, true) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($equalZero); + $this->assertEquals('p0', $equalZero[0]->getId()); + + // distanceNotEqual with 0m should return p1 + $notEqualZero = $database->find($collectionName, [ + Query::distanceNotEqual('loc', [0.0000, 0.0000], 0, true) + ], Database::PERMISSION_READ); + $this->assertNotEmpty($notEqualZero); + $this->assertEquals('p1', $notEqualZero[0]->getId()); + } finally { + $database->deleteCollection($collectionName); + } + } } diff --git a/tests/unit/Validator/SpatialTest.php b/tests/unit/Validator/SpatialTest.php new file mode 100644 index 000000000..8fe83a870 --- /dev/null +++ b/tests/unit/Validator/SpatialTest.php @@ -0,0 +1,84 @@ +assertTrue($validator->isValid([10, 20])); + $this->assertTrue($validator->isValid([0, 0])); + $this->assertTrue($validator->isValid([-180.0, 90.0])); + + // Invalid cases + $this->assertFalse($validator->isValid([10])); // Only one coordinate + $this->assertFalse($validator->isValid([10, 'a'])); // Non-numeric + $this->assertFalse($validator->isValid([[10, 20]])); // Nested array + } + + public function testValidLineString(): void + { + $validator = new Spatial(Database::VAR_LINESTRING); + + $this->assertTrue($validator->isValid([[0, 0], [1, 1]])); + + $this->assertTrue($validator->isValid([[10, 10], [20, 20], [30, 30]])); + + // Invalid cases + $this->assertFalse($validator->isValid([[10, 10]])); // Only one point + $this->assertFalse($validator->isValid([[10, 10], [20]])); // Malformed point + $this->assertFalse($validator->isValid([[10, 10], ['x', 'y']])); // Non-numeric + } + + public function testValidPolygon(): void + { + $validator = new Spatial(Database::VAR_POLYGON); + + // Single ring polygon (closed) + $this->assertTrue($validator->isValid([ + [0, 0], + [0, 1], + [1, 1], + [1, 0], + [0, 0] + ])); + + // Multi-ring polygon + $this->assertTrue($validator->isValid([ + [ // Outer ring + [0, 0], [0, 4], [4, 4], [4, 0], [0, 0] + ], + [ // Hole + [1, 1], [1, 2], [2, 2], [2, 1], [1, 1] + ] + ])); + + // Invalid polygons + $this->assertFalse($validator->isValid([])); // Empty + $this->assertFalse($validator->isValid([ + [0, 0], [1, 1], [2, 2] // Not closed, less than 4 points + ])); + $this->assertFalse($validator->isValid([ + [[0, 0], [1, 1], [1, 0]] // Not closed + ])); + $this->assertFalse($validator->isValid([ + [[0, 0], [1, 1], [1, 'a'], [0, 0]] // Non-numeric + ])); + } + + public function testWKTStrings(): void + { + $this->assertTrue(Spatial::isWKTString('POINT(1 2)')); + $this->assertTrue(Spatial::isWKTString('LINESTRING(0 0,1 1)')); + $this->assertTrue(Spatial::isWKTString('POLYGON((0 0,1 0,1 1,0 1,0 0))')); + + $this->assertFalse(Spatial::isWKTString('CIRCLE(0 0,1)')); + $this->assertFalse(Spatial::isWKTString('POINT1(1 2)')); + } +} From 3336ebc4cccaef7a6e28b763fc83b1f773394d35 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 2 Sep 2025 20:45:15 +0530 Subject: [PATCH 4/6] updated phpdocs for handledistancespatial --- src/Database/Adapter/MySQL.php | 10 ++++++++++ src/Database/Adapter/Postgres.php | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/Database/Adapter/MySQL.php b/src/Database/Adapter/MySQL.php index 84b3cb9e9..1603201f2 100644 --- a/src/Database/Adapter/MySQL.php +++ b/src/Database/Adapter/MySQL.php @@ -79,6 +79,16 @@ public function getSizeOfCollectionOnDisk(string $collection): int return $size; } + /** + * Handle distance spatial queries + * + * @param Query $query + * @param array $binds + * @param string $attribute + * @param string $alias + * @param string $placeholder + * @return string + */ protected function handleDistanceSpatialQueries(Query $query, array &$binds, string $attribute, string $alias, string $placeholder): string { $distanceParams = $query->getValues()[0]; diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index be462161b..2ace9f555 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -1452,6 +1452,16 @@ public function getConnectionId(): string return $stmt->fetchColumn(); } + /** + * Handle distance spatial queries + * + * @param Query $query + * @param array $binds + * @param string $attribute + * @param string $alias + * @param string $placeholder + * @return string + */ protected function handleDistanceSpatialQueries(Query $query, array &$binds, string $attribute, string $alias, string $placeholder): string { $distanceParams = $query->getValues()[0]; From ddabbf8b4b326bbdc104a313b99ff7ea6c39acf6 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 2 Sep 2025 21:37:07 +0530 Subject: [PATCH 5/6] Refactor spatial query handling to improve clarity and consistency in distance calculations --- src/Database/Adapter/MariaDB.php | 4 ++-- src/Database/Adapter/MySQL.php | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index c7a989f2e..d9ea2d130 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1368,7 +1368,7 @@ protected function handleDistanceSpatialQueries(Query $query, array &$binds, str $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); $binds[":{$placeholder}_1"] = $distanceParams[1]; - $meters = isset($distanceParams[2]) && $distanceParams[2] === true; + $useMeters = isset($distanceParams[2]) && $distanceParams[2] === true; switch ($query->getMethod()) { case Query::TYPE_DISTANCE_EQUAL: @@ -1387,7 +1387,7 @@ protected function handleDistanceSpatialQueries(Query $query, array &$binds, str throw new DatabaseException('Unknown spatial query method: ' . $query->getMethod()); } - if ($meters) { + if ($useMeters) { return "ST_DISTANCE_SPHERE({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0), 6371000) {$operator} :{$placeholder}_1"; } return "ST_Distance({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0)) {$operator} :{$placeholder}_1"; diff --git a/src/Database/Adapter/MySQL.php b/src/Database/Adapter/MySQL.php index 1603201f2..682ce18ef 100644 --- a/src/Database/Adapter/MySQL.php +++ b/src/Database/Adapter/MySQL.php @@ -114,9 +114,14 @@ protected function handleDistanceSpatialQueries(Query $query, array &$binds, str throw new DatabaseException('Unknown spatial query method: ' . $query->getMethod()); } - $unit = $useMeters ? ", 'meter'" : ''; + if ($useMeters) { + $attr = "ST_SRID({$alias}.{$attribute}, " . Database::SRID . ")"; + $geom = "ST_GeomFromText(:{$placeholder}_0, " . Database::SRID . ")"; + return "ST_Distance({$attr}, {$geom}, 'metre') {$operator} :{$placeholder}_1"; + } - return "ST_Distance({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0){$unit}) {$operator} :{$placeholder}_1"; + // Without meters, use default behavior + return "ST_Distance({$alias}.{$attribute}, ST_GeomFromText(:{$placeholder}_0)) {$operator} :{$placeholder}_1"; } public function getSupportForIndexArray(): bool From 49bb55af9303d5f8244003c602b128adb919eb23 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 2 Sep 2025 21:39:04 +0530 Subject: [PATCH 6/6] updated meter param for distance --- src/Database/Query.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Database/Query.php b/src/Database/Query.php index adc2b524f..8740820dd 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -920,7 +920,7 @@ public static function distanceEqual(string $attribute, array $values, int|float * @param bool $meters * @return Query */ - public static function distanceNotEqual(string $attribute, array $values, int|float $distance, $meters = false): self + public static function distanceNotEqual(string $attribute, array $values, int|float $distance, bool $meters = false): self { return new self(self::TYPE_DISTANCE_NOT_EQUAL, $attribute, [[$values,$distance,$meters]]); } @@ -934,7 +934,7 @@ public static function distanceNotEqual(string $attribute, array $values, int|fl * @param bool $meters * @return Query */ - public static function distanceGreaterThan(string $attribute, array $values, int|float $distance, $meters = false): self + public static function distanceGreaterThan(string $attribute, array $values, int|float $distance, bool $meters = false): self { return new self(self::TYPE_DISTANCE_GREATER_THAN, $attribute, [[$values,$distance, $meters]]); }