diff --git a/src/Database/Database.php b/src/Database/Database.php index eb1f9c0d3..19eed670e 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1852,6 +1852,12 @@ private function validateAttribute( if (!$this->adapter->getSupportForSpatialAttributes()) { throw new DatabaseException('Spatial attributes are not supported'); } + if (!empty($size)) { + throw new DatabaseException('Size must be empty for spatial attributes'); + } + if (!empty($array)) { + throw new DatabaseException('Spatial attributes cannot be arrays'); + } break; default: throw new DatabaseException('Unknown attribute type: ' . $type . '. Must be one of ' . self::VAR_STRING . ', ' . self::VAR_INTEGER . ', ' . self::VAR_FLOAT . ', ' . self::VAR_BOOLEAN . ', ' . self::VAR_DATETIME . ', ' . self::VAR_RELATIONSHIP . ', ' . self::VAR_POINT . ', ' . self::VAR_LINESTRING . ', ' . self::VAR_POLYGON); @@ -1903,8 +1909,11 @@ protected function validateDefaultTypes(string $type, mixed $default): void } if ($defaultType === 'array') { - foreach ($default as $value) { - $this->validateDefaultTypes($type, $value); + // spatial types require the array itself + if (!in_array($type, Database::SPATIAL_TYPES)) { + foreach ($default as $value) { + $this->validateDefaultTypes($type, $value); + } } return; } @@ -2173,6 +2182,20 @@ public function updateAttribute(string $collection, string $id, ?string $type = throw new DatabaseException('Size must be empty'); } break; + + case self::VAR_POINT: + case self::VAR_LINESTRING: + case self::VAR_POLYGON: + if (!$this->adapter->getSupportForSpatialAttributes()) { + throw new DatabaseException('Spatial attributes are not supported'); + } + if (!empty($size)) { + throw new DatabaseException('Size must be empty for spatial attributes'); + } + if (!empty($array)) { + throw new DatabaseException('Spatial attributes cannot be arrays'); + } + break; default: throw new DatabaseException('Unknown attribute type: ' . $type . '. Must be one of ' . self::VAR_STRING . ', ' . self::VAR_INTEGER . ', ' . self::VAR_FLOAT . ', ' . self::VAR_BOOLEAN . ', ' . self::VAR_DATETIME . ', ' . self::VAR_RELATIONSHIP); } @@ -2221,6 +2244,35 @@ public function updateAttribute(string $collection, string $id, ?string $type = throw new LimitException('Row width limit reached. Cannot update attribute.'); } + if (in_array($type, self::SPATIAL_TYPES, true) && !$this->adapter->getSupportForSpatialIndexNull()) { + $attributeMap = []; + foreach ($attributes as $attrDoc) { + $key = \strtolower($attrDoc->getAttribute('key', $attrDoc->getAttribute('$id'))); + $attributeMap[$key] = $attrDoc; + } + + $indexes = $collectionDoc->getAttribute('indexes', []); + foreach ($indexes as $index) { + if ($index->getAttribute('type') !== self::INDEX_SPATIAL) { + continue; + } + $indexAttributes = $index->getAttribute('attributes', []); + foreach ($indexAttributes as $attributeName) { + $lookup = \strtolower($attributeName); + if (!isset($attributeMap[$lookup])) { + continue; + } + $attrDoc = $attributeMap[$lookup]; + $attrType = $attrDoc->getAttribute('type'); + $attrRequired = (bool)$attrDoc->getAttribute('required', false); + + if (in_array($attrType, self::SPATIAL_TYPES, true) && !$attrRequired) { + throw new IndexException('Spatial indexes do not allow null values. Mark the attribute "' . $attributeName . '" as required or create the index on a column with no null values.'); + } + } + } + } + if ($altering) { $indexes = $collectionDoc->getAttribute('indexes'); diff --git a/tests/e2e/Adapter/Scopes/SpatialTests.php b/tests/e2e/Adapter/Scopes/SpatialTests.php index 8f6c2898b..63f1b3c49 100644 --- a/tests/e2e/Adapter/Scopes/SpatialTests.php +++ b/tests/e2e/Adapter/Scopes/SpatialTests.php @@ -4,6 +4,7 @@ use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Exception; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; @@ -340,7 +341,12 @@ public function testSpatialAttributes(): void // Create spatial indexes $this->assertEquals(true, $database->createIndex($collectionName, 'idx_point', Database::INDEX_SPATIAL, ['pointAttr'])); - $this->assertEquals(true, $database->createIndex($collectionName, 'idx_line', Database::INDEX_SPATIAL, ['lineAttr'])); + if ($database->getAdapter()->getSupportForSpatialIndexNull()) { + $this->assertEquals(true, $database->createIndex($collectionName, 'idx_line', Database::INDEX_SPATIAL, ['lineAttr'])); + } else { + // Attribute was created as required above; directly create index once + $this->assertEquals(true, $database->createIndex($collectionName, 'idx_line', Database::INDEX_SPATIAL, ['lineAttr'])); + } $this->assertEquals(true, $database->createIndex($collectionName, 'idx_poly', Database::INDEX_SPATIAL, ['polyAttr'])); $collection = $database->getCollection($collectionName); @@ -1773,4 +1779,194 @@ public function testSptialAggregation(): void $database->deleteCollection($collectionName); } } + + public function testUpdateSpatialAttributes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->markTestSkipped('Adapter does not support spatial attributes'); + } + + $collectionName = 'spatial_update_attrs_'; + try { + $database->createCollection($collectionName); + + // 0) Disallow creation of spatial attributes with size or array + try { + $database->createAttribute($collectionName, 'geom_bad_size', Database::VAR_POINT, 10, true); + $this->fail('Expected DatabaseException when creating spatial attribute with non-zero size'); + } catch (\Throwable $e) { + $this->assertInstanceOf(Exception::class, $e); + } + + try { + $database->createAttribute($collectionName, 'geom_bad_array', Database::VAR_POINT, 0, true, array: true); + $this->fail('Expected DatabaseException when creating spatial attribute with array=true'); + } catch (\Throwable $e) { + $this->assertInstanceOf(Exception::class, $e); + } + + // Create a single spatial attribute (required=true) + $this->assertEquals(true, $database->createAttribute($collectionName, 'geom', Database::VAR_POINT, 0, true)); + $this->assertEquals(true, $database->createIndex($collectionName, 'idx_geom', Database::INDEX_SPATIAL, ['geom'])); + + // 1) Disallow size and array updates on spatial attributes: expect DatabaseException + try { + $database->updateAttribute($collectionName, 'geom', size: 10); + $this->fail('Expected DatabaseException when updating size on spatial attribute'); + } catch (\Throwable $e) { + $this->assertInstanceOf(Exception::class, $e); + } + + try { + $database->updateAttribute($collectionName, 'geom', array: true); + $this->fail('Expected DatabaseException when updating array on spatial attribute'); + } catch (\Throwable $e) { + $this->assertInstanceOf(Exception::class, $e); + } + + // 2) required=true -> create index -> update required=false + $nullSupported = $database->getAdapter()->getSupportForSpatialIndexNull(); + if ($nullSupported) { + // Should succeed on adapters that allow nullable spatial indexes + $database->updateAttribute($collectionName, 'geom', required: false); + $meta = $database->getCollection($collectionName); + $this->assertEquals(false, $meta->getAttribute('attributes')[0]['required']); + } else { + // Should error (index constraint) when making required=false while spatial index exists + $threw = false; + try { + $database->updateAttribute($collectionName, 'geom', required: false); + } catch (\Throwable $e) { + $threw = true; + } + $this->assertTrue($threw, 'Expected error when setting required=false with existing spatial index and adapter not supporting nullable indexes'); + // Ensure attribute remains required + $meta = $database->getCollection($collectionName); + $this->assertEquals(true, $meta->getAttribute('attributes')[0]['required']); + } + + // 3) Spatial index order support: providing orders should fail if not supported + $orderSupported = $database->getAdapter()->getSupportForSpatialIndexOrder(); + if ($orderSupported) { + $this->assertTrue($database->createIndex($collectionName, 'idx_geom_desc', Database::INDEX_SPATIAL, ['geom'], [], [Database::ORDER_DESC])); + // cleanup + $this->assertTrue($database->deleteIndex($collectionName, 'idx_geom_desc')); + } else { + try { + $database->createIndex($collectionName, 'idx_geom_desc', Database::INDEX_SPATIAL, ['geom'], [], ['DESC']); + $this->fail('Expected error when providing orders for spatial index on adapter without order support'); + } catch (\Throwable $e) { + $this->assertTrue(true); + } + } + } finally { + $database->deleteCollection($collectionName); + } + } + + public function testSpatialAttributeDefaults(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + if (!$database->getAdapter()->getSupportForSpatialAttributes()) { + $this->markTestSkipped('Adapter does not support spatial attributes'); + } + + $collectionName = 'spatial_defaults_'; + try { + $database->createCollection($collectionName); + + // Create spatial attributes with defaults and no indexes to avoid nullability/index constraints + $this->assertEquals(true, $database->createAttribute($collectionName, 'pt', Database::VAR_POINT, 0, false, [1.0, 2.0])); + $this->assertEquals(true, $database->createAttribute($collectionName, 'ln', Database::VAR_LINESTRING, 0, false, [[0.0, 0.0], [1.0, 1.0]])); + $this->assertEquals(true, $database->createAttribute($collectionName, 'pg', Database::VAR_POLYGON, 0, false, [[[0.0, 0.0], [0.0, 2.0], [2.0, 2.0], [0.0, 0.0]]])); + + // Create non-spatial attributes (mix of defaults and no defaults) + $this->assertEquals(true, $database->createAttribute($collectionName, 'title', Database::VAR_STRING, 255, false, 'Untitled')); + $this->assertEquals(true, $database->createAttribute($collectionName, 'count', Database::VAR_INTEGER, 0, false, 0)); + $this->assertEquals(true, $database->createAttribute($collectionName, 'rating', Database::VAR_FLOAT, 0, false)); // no default + $this->assertEquals(true, $database->createAttribute($collectionName, 'active', Database::VAR_BOOLEAN, 0, false, true)); + + // Create document without providing spatial values, expect defaults applied + $doc = $database->createDocument($collectionName, new Document([ + '$id' => ID::custom('d1'), + '$permissions' => [Permission::read(Role::any())] + ])); + $this->assertInstanceOf(Document::class, $doc); + $this->assertEquals([1.0, 2.0], $doc->getAttribute('pt')); + $this->assertEquals([[0.0, 0.0], [1.0, 1.0]], $doc->getAttribute('ln')); + $this->assertEquals([[[0.0, 0.0], [0.0, 2.0], [2.0, 2.0], [0.0, 0.0]]], $doc->getAttribute('pg')); + // Non-spatial defaults + $this->assertEquals('Untitled', $doc->getAttribute('title')); + $this->assertEquals(0, $doc->getAttribute('count')); + $this->assertNull($doc->getAttribute('rating')); + $this->assertTrue($doc->getAttribute('active')); + + // Create document overriding defaults + $doc2 = $database->createDocument($collectionName, new Document([ + '$id' => ID::custom('d2'), + '$permissions' => [Permission::read(Role::any())], + 'pt' => [9.0, 9.0], + 'ln' => [[2.0, 2.0], [3.0, 3.0]], + 'pg' => [[[1.0, 1.0], [1.0, 3.0], [3.0, 3.0], [1.0, 1.0]]], + 'title' => 'Custom', + 'count' => 5, + 'rating' => 4.5, + 'active' => false + ])); + $this->assertInstanceOf(Document::class, $doc2); + $this->assertEquals([9.0, 9.0], $doc2->getAttribute('pt')); + $this->assertEquals([[2.0, 2.0], [3.0, 3.0]], $doc2->getAttribute('ln')); + $this->assertEquals([[[1.0, 1.0], [1.0, 3.0], [3.0, 3.0], [1.0, 1.0]]], $doc2->getAttribute('pg')); + $this->assertEquals('Custom', $doc2->getAttribute('title')); + $this->assertEquals(5, $doc2->getAttribute('count')); + $this->assertEquals(4.5, $doc2->getAttribute('rating')); + $this->assertFalse($doc2->getAttribute('active')); + + // Update defaults and ensure they are applied for new documents + $database->updateAttributeDefault($collectionName, 'pt', [5.0, 6.0]); + $database->updateAttributeDefault($collectionName, 'ln', [[10.0, 10.0], [20.0, 20.0]]); + $database->updateAttributeDefault($collectionName, 'pg', [[[5.0, 5.0], [5.0, 7.0], [7.0, 7.0], [5.0, 5.0]]]); + $database->updateAttributeDefault($collectionName, 'title', 'Updated'); + $database->updateAttributeDefault($collectionName, 'count', 10); + $database->updateAttributeDefault($collectionName, 'active', false); + + $doc3 = $database->createDocument($collectionName, new Document([ + '$id' => ID::custom('d3'), + '$permissions' => [Permission::read(Role::any())] + ])); + $this->assertInstanceOf(Document::class, $doc3); + $this->assertEquals([5.0, 6.0], $doc3->getAttribute('pt')); + $this->assertEquals([[10.0, 10.0], [20.0, 20.0]], $doc3->getAttribute('ln')); + $this->assertEquals([[[5.0, 5.0], [5.0, 7.0], [7.0, 7.0], [5.0, 5.0]]], $doc3->getAttribute('pg')); + $this->assertEquals('Updated', $doc3->getAttribute('title')); + $this->assertEquals(10, $doc3->getAttribute('count')); + $this->assertNull($doc3->getAttribute('rating')); + $this->assertFalse($doc3->getAttribute('active')); + + // Invalid defaults should raise errors + try { + $database->updateAttributeDefault($collectionName, 'pt', [[1.0, 2.0]]); // wrong dimensionality + $this->fail('Expected exception for invalid point default shape'); + } catch (\Throwable $e) { + $this->assertTrue(true); + } + try { + $database->updateAttributeDefault($collectionName, 'ln', [1.0, 2.0]); // wrong dimensionality + $this->fail('Expected exception for invalid linestring default shape'); + } catch (\Throwable $e) { + $this->assertTrue(true); + } + try { + $database->updateAttributeDefault($collectionName, 'pg', [[1.0, 2.0]]); // wrong dimensionality + $this->fail('Expected exception for invalid polygon default shape'); + } catch (\Throwable $e) { + $this->assertTrue(true); + } + } finally { + $database->deleteCollection($collectionName); + } + } }