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
56 changes: 54 additions & 2 deletions src/Database/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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');

Expand Down
198 changes: 197 additions & 1 deletion tests/e2e/Adapter/Scopes/SpatialTests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}
}