Skip to content

Commit d59a207

Browse files
authored
Merge pull request #675 from ArnabChatterjee20k/spatial-attribute-support
Spatial attribute update attribute and default fix
2 parents 99beaf1 + c98b33b commit d59a207

File tree

2 files changed

+251
-3
lines changed

2 files changed

+251
-3
lines changed

src/Database/Database.php

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1852,6 +1852,12 @@ private function validateAttribute(
18521852
if (!$this->adapter->getSupportForSpatialAttributes()) {
18531853
throw new DatabaseException('Spatial attributes are not supported');
18541854
}
1855+
if (!empty($size)) {
1856+
throw new DatabaseException('Size must be empty for spatial attributes');
1857+
}
1858+
if (!empty($array)) {
1859+
throw new DatabaseException('Spatial attributes cannot be arrays');
1860+
}
18551861
break;
18561862
default:
18571863
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
19031909
}
19041910

19051911
if ($defaultType === 'array') {
1906-
foreach ($default as $value) {
1907-
$this->validateDefaultTypes($type, $value);
1912+
// spatial types require the array itself
1913+
if (!in_array($type, Database::SPATIAL_TYPES)) {
1914+
foreach ($default as $value) {
1915+
$this->validateDefaultTypes($type, $value);
1916+
}
19081917
}
19091918
return;
19101919
}
@@ -2173,6 +2182,20 @@ public function updateAttribute(string $collection, string $id, ?string $type =
21732182
throw new DatabaseException('Size must be empty');
21742183
}
21752184
break;
2185+
2186+
case self::VAR_POINT:
2187+
case self::VAR_LINESTRING:
2188+
case self::VAR_POLYGON:
2189+
if (!$this->adapter->getSupportForSpatialAttributes()) {
2190+
throw new DatabaseException('Spatial attributes are not supported');
2191+
}
2192+
if (!empty($size)) {
2193+
throw new DatabaseException('Size must be empty for spatial attributes');
2194+
}
2195+
if (!empty($array)) {
2196+
throw new DatabaseException('Spatial attributes cannot be arrays');
2197+
}
2198+
break;
21762199
default:
21772200
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);
21782201
}
@@ -2221,6 +2244,35 @@ public function updateAttribute(string $collection, string $id, ?string $type =
22212244
throw new LimitException('Row width limit reached. Cannot update attribute.');
22222245
}
22232246

2247+
if (in_array($type, self::SPATIAL_TYPES, true) && !$this->adapter->getSupportForSpatialIndexNull()) {
2248+
$attributeMap = [];
2249+
foreach ($attributes as $attrDoc) {
2250+
$key = \strtolower($attrDoc->getAttribute('key', $attrDoc->getAttribute('$id')));
2251+
$attributeMap[$key] = $attrDoc;
2252+
}
2253+
2254+
$indexes = $collectionDoc->getAttribute('indexes', []);
2255+
foreach ($indexes as $index) {
2256+
if ($index->getAttribute('type') !== self::INDEX_SPATIAL) {
2257+
continue;
2258+
}
2259+
$indexAttributes = $index->getAttribute('attributes', []);
2260+
foreach ($indexAttributes as $attributeName) {
2261+
$lookup = \strtolower($attributeName);
2262+
if (!isset($attributeMap[$lookup])) {
2263+
continue;
2264+
}
2265+
$attrDoc = $attributeMap[$lookup];
2266+
$attrType = $attrDoc->getAttribute('type');
2267+
$attrRequired = (bool)$attrDoc->getAttribute('required', false);
2268+
2269+
if (in_array($attrType, self::SPATIAL_TYPES, true) && !$attrRequired) {
2270+
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.');
2271+
}
2272+
}
2273+
}
2274+
}
2275+
22242276
if ($altering) {
22252277
$indexes = $collectionDoc->getAttribute('indexes');
22262278

tests/e2e/Adapter/Scopes/SpatialTests.php

Lines changed: 197 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Utopia\Database\Database;
66
use Utopia\Database\Document;
7+
use Utopia\Database\Exception;
78
use Utopia\Database\Helpers\ID;
89
use Utopia\Database\Helpers\Permission;
910
use Utopia\Database\Helpers\Role;
@@ -340,7 +341,12 @@ public function testSpatialAttributes(): void
340341

341342
// Create spatial indexes
342343
$this->assertEquals(true, $database->createIndex($collectionName, 'idx_point', Database::INDEX_SPATIAL, ['pointAttr']));
343-
$this->assertEquals(true, $database->createIndex($collectionName, 'idx_line', Database::INDEX_SPATIAL, ['lineAttr']));
344+
if ($database->getAdapter()->getSupportForSpatialIndexNull()) {
345+
$this->assertEquals(true, $database->createIndex($collectionName, 'idx_line', Database::INDEX_SPATIAL, ['lineAttr']));
346+
} else {
347+
// Attribute was created as required above; directly create index once
348+
$this->assertEquals(true, $database->createIndex($collectionName, 'idx_line', Database::INDEX_SPATIAL, ['lineAttr']));
349+
}
344350
$this->assertEquals(true, $database->createIndex($collectionName, 'idx_poly', Database::INDEX_SPATIAL, ['polyAttr']));
345351

346352
$collection = $database->getCollection($collectionName);
@@ -1773,4 +1779,194 @@ public function testSptialAggregation(): void
17731779
$database->deleteCollection($collectionName);
17741780
}
17751781
}
1782+
1783+
public function testUpdateSpatialAttributes(): void
1784+
{
1785+
/** @var Database $database */
1786+
$database = static::getDatabase();
1787+
if (!$database->getAdapter()->getSupportForSpatialAttributes()) {
1788+
$this->markTestSkipped('Adapter does not support spatial attributes');
1789+
}
1790+
1791+
$collectionName = 'spatial_update_attrs_';
1792+
try {
1793+
$database->createCollection($collectionName);
1794+
1795+
// 0) Disallow creation of spatial attributes with size or array
1796+
try {
1797+
$database->createAttribute($collectionName, 'geom_bad_size', Database::VAR_POINT, 10, true);
1798+
$this->fail('Expected DatabaseException when creating spatial attribute with non-zero size');
1799+
} catch (\Throwable $e) {
1800+
$this->assertInstanceOf(Exception::class, $e);
1801+
}
1802+
1803+
try {
1804+
$database->createAttribute($collectionName, 'geom_bad_array', Database::VAR_POINT, 0, true, array: true);
1805+
$this->fail('Expected DatabaseException when creating spatial attribute with array=true');
1806+
} catch (\Throwable $e) {
1807+
$this->assertInstanceOf(Exception::class, $e);
1808+
}
1809+
1810+
// Create a single spatial attribute (required=true)
1811+
$this->assertEquals(true, $database->createAttribute($collectionName, 'geom', Database::VAR_POINT, 0, true));
1812+
$this->assertEquals(true, $database->createIndex($collectionName, 'idx_geom', Database::INDEX_SPATIAL, ['geom']));
1813+
1814+
// 1) Disallow size and array updates on spatial attributes: expect DatabaseException
1815+
try {
1816+
$database->updateAttribute($collectionName, 'geom', size: 10);
1817+
$this->fail('Expected DatabaseException when updating size on spatial attribute');
1818+
} catch (\Throwable $e) {
1819+
$this->assertInstanceOf(Exception::class, $e);
1820+
}
1821+
1822+
try {
1823+
$database->updateAttribute($collectionName, 'geom', array: true);
1824+
$this->fail('Expected DatabaseException when updating array on spatial attribute');
1825+
} catch (\Throwable $e) {
1826+
$this->assertInstanceOf(Exception::class, $e);
1827+
}
1828+
1829+
// 2) required=true -> create index -> update required=false
1830+
$nullSupported = $database->getAdapter()->getSupportForSpatialIndexNull();
1831+
if ($nullSupported) {
1832+
// Should succeed on adapters that allow nullable spatial indexes
1833+
$database->updateAttribute($collectionName, 'geom', required: false);
1834+
$meta = $database->getCollection($collectionName);
1835+
$this->assertEquals(false, $meta->getAttribute('attributes')[0]['required']);
1836+
} else {
1837+
// Should error (index constraint) when making required=false while spatial index exists
1838+
$threw = false;
1839+
try {
1840+
$database->updateAttribute($collectionName, 'geom', required: false);
1841+
} catch (\Throwable $e) {
1842+
$threw = true;
1843+
}
1844+
$this->assertTrue($threw, 'Expected error when setting required=false with existing spatial index and adapter not supporting nullable indexes');
1845+
// Ensure attribute remains required
1846+
$meta = $database->getCollection($collectionName);
1847+
$this->assertEquals(true, $meta->getAttribute('attributes')[0]['required']);
1848+
}
1849+
1850+
// 3) Spatial index order support: providing orders should fail if not supported
1851+
$orderSupported = $database->getAdapter()->getSupportForSpatialIndexOrder();
1852+
if ($orderSupported) {
1853+
$this->assertTrue($database->createIndex($collectionName, 'idx_geom_desc', Database::INDEX_SPATIAL, ['geom'], [], [Database::ORDER_DESC]));
1854+
// cleanup
1855+
$this->assertTrue($database->deleteIndex($collectionName, 'idx_geom_desc'));
1856+
} else {
1857+
try {
1858+
$database->createIndex($collectionName, 'idx_geom_desc', Database::INDEX_SPATIAL, ['geom'], [], ['DESC']);
1859+
$this->fail('Expected error when providing orders for spatial index on adapter without order support');
1860+
} catch (\Throwable $e) {
1861+
$this->assertTrue(true);
1862+
}
1863+
}
1864+
} finally {
1865+
$database->deleteCollection($collectionName);
1866+
}
1867+
}
1868+
1869+
public function testSpatialAttributeDefaults(): void
1870+
{
1871+
/** @var Database $database */
1872+
$database = static::getDatabase();
1873+
if (!$database->getAdapter()->getSupportForSpatialAttributes()) {
1874+
$this->markTestSkipped('Adapter does not support spatial attributes');
1875+
}
1876+
1877+
$collectionName = 'spatial_defaults_';
1878+
try {
1879+
$database->createCollection($collectionName);
1880+
1881+
// Create spatial attributes with defaults and no indexes to avoid nullability/index constraints
1882+
$this->assertEquals(true, $database->createAttribute($collectionName, 'pt', Database::VAR_POINT, 0, false, [1.0, 2.0]));
1883+
$this->assertEquals(true, $database->createAttribute($collectionName, 'ln', Database::VAR_LINESTRING, 0, false, [[0.0, 0.0], [1.0, 1.0]]));
1884+
$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]]]));
1885+
1886+
// Create non-spatial attributes (mix of defaults and no defaults)
1887+
$this->assertEquals(true, $database->createAttribute($collectionName, 'title', Database::VAR_STRING, 255, false, 'Untitled'));
1888+
$this->assertEquals(true, $database->createAttribute($collectionName, 'count', Database::VAR_INTEGER, 0, false, 0));
1889+
$this->assertEquals(true, $database->createAttribute($collectionName, 'rating', Database::VAR_FLOAT, 0, false)); // no default
1890+
$this->assertEquals(true, $database->createAttribute($collectionName, 'active', Database::VAR_BOOLEAN, 0, false, true));
1891+
1892+
// Create document without providing spatial values, expect defaults applied
1893+
$doc = $database->createDocument($collectionName, new Document([
1894+
'$id' => ID::custom('d1'),
1895+
'$permissions' => [Permission::read(Role::any())]
1896+
]));
1897+
$this->assertInstanceOf(Document::class, $doc);
1898+
$this->assertEquals([1.0, 2.0], $doc->getAttribute('pt'));
1899+
$this->assertEquals([[0.0, 0.0], [1.0, 1.0]], $doc->getAttribute('ln'));
1900+
$this->assertEquals([[[0.0, 0.0], [0.0, 2.0], [2.0, 2.0], [0.0, 0.0]]], $doc->getAttribute('pg'));
1901+
// Non-spatial defaults
1902+
$this->assertEquals('Untitled', $doc->getAttribute('title'));
1903+
$this->assertEquals(0, $doc->getAttribute('count'));
1904+
$this->assertNull($doc->getAttribute('rating'));
1905+
$this->assertTrue($doc->getAttribute('active'));
1906+
1907+
// Create document overriding defaults
1908+
$doc2 = $database->createDocument($collectionName, new Document([
1909+
'$id' => ID::custom('d2'),
1910+
'$permissions' => [Permission::read(Role::any())],
1911+
'pt' => [9.0, 9.0],
1912+
'ln' => [[2.0, 2.0], [3.0, 3.0]],
1913+
'pg' => [[[1.0, 1.0], [1.0, 3.0], [3.0, 3.0], [1.0, 1.0]]],
1914+
'title' => 'Custom',
1915+
'count' => 5,
1916+
'rating' => 4.5,
1917+
'active' => false
1918+
]));
1919+
$this->assertInstanceOf(Document::class, $doc2);
1920+
$this->assertEquals([9.0, 9.0], $doc2->getAttribute('pt'));
1921+
$this->assertEquals([[2.0, 2.0], [3.0, 3.0]], $doc2->getAttribute('ln'));
1922+
$this->assertEquals([[[1.0, 1.0], [1.0, 3.0], [3.0, 3.0], [1.0, 1.0]]], $doc2->getAttribute('pg'));
1923+
$this->assertEquals('Custom', $doc2->getAttribute('title'));
1924+
$this->assertEquals(5, $doc2->getAttribute('count'));
1925+
$this->assertEquals(4.5, $doc2->getAttribute('rating'));
1926+
$this->assertFalse($doc2->getAttribute('active'));
1927+
1928+
// Update defaults and ensure they are applied for new documents
1929+
$database->updateAttributeDefault($collectionName, 'pt', [5.0, 6.0]);
1930+
$database->updateAttributeDefault($collectionName, 'ln', [[10.0, 10.0], [20.0, 20.0]]);
1931+
$database->updateAttributeDefault($collectionName, 'pg', [[[5.0, 5.0], [5.0, 7.0], [7.0, 7.0], [5.0, 5.0]]]);
1932+
$database->updateAttributeDefault($collectionName, 'title', 'Updated');
1933+
$database->updateAttributeDefault($collectionName, 'count', 10);
1934+
$database->updateAttributeDefault($collectionName, 'active', false);
1935+
1936+
$doc3 = $database->createDocument($collectionName, new Document([
1937+
'$id' => ID::custom('d3'),
1938+
'$permissions' => [Permission::read(Role::any())]
1939+
]));
1940+
$this->assertInstanceOf(Document::class, $doc3);
1941+
$this->assertEquals([5.0, 6.0], $doc3->getAttribute('pt'));
1942+
$this->assertEquals([[10.0, 10.0], [20.0, 20.0]], $doc3->getAttribute('ln'));
1943+
$this->assertEquals([[[5.0, 5.0], [5.0, 7.0], [7.0, 7.0], [5.0, 5.0]]], $doc3->getAttribute('pg'));
1944+
$this->assertEquals('Updated', $doc3->getAttribute('title'));
1945+
$this->assertEquals(10, $doc3->getAttribute('count'));
1946+
$this->assertNull($doc3->getAttribute('rating'));
1947+
$this->assertFalse($doc3->getAttribute('active'));
1948+
1949+
// Invalid defaults should raise errors
1950+
try {
1951+
$database->updateAttributeDefault($collectionName, 'pt', [[1.0, 2.0]]); // wrong dimensionality
1952+
$this->fail('Expected exception for invalid point default shape');
1953+
} catch (\Throwable $e) {
1954+
$this->assertTrue(true);
1955+
}
1956+
try {
1957+
$database->updateAttributeDefault($collectionName, 'ln', [1.0, 2.0]); // wrong dimensionality
1958+
$this->fail('Expected exception for invalid linestring default shape');
1959+
} catch (\Throwable $e) {
1960+
$this->assertTrue(true);
1961+
}
1962+
try {
1963+
$database->updateAttributeDefault($collectionName, 'pg', [[1.0, 2.0]]); // wrong dimensionality
1964+
$this->fail('Expected exception for invalid polygon default shape');
1965+
} catch (\Throwable $e) {
1966+
$this->assertTrue(true);
1967+
}
1968+
} finally {
1969+
$database->deleteCollection($collectionName);
1970+
}
1971+
}
17761972
}

0 commit comments

Comments
 (0)