Skip to content

Commit 486c7c6

Browse files
committed
Fix spatial handling
1 parent fd22110 commit 486c7c6

File tree

6 files changed

+381
-14
lines changed

6 files changed

+381
-14
lines changed

src/Database/Adapter/MariaDB.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1165,7 +1165,12 @@ public function updateDocument(Document $collection, string $id, Document $docum
11651165
if (isset($operators[$attribute])) {
11661166
$this->bindOperatorParams($stmt, $operators[$attribute], $attributeIndex);
11671167
} else {
1168-
if (is_array($value)) {
1168+
// Convert spatial arrays to WKT, json_encode non-spatial arrays
1169+
if (\in_array($attribute, $spatialAttributes, true)) {
1170+
if (\is_array($value)) {
1171+
$value = $this->convertArrayToWKT($value);
1172+
}
1173+
} elseif (is_array($value)) {
11691174
$value = json_encode($value);
11701175
}
11711176

src/Database/Adapter/Postgres.php

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1092,6 +1092,7 @@ public function createDocument(Document $collection, Document $document): Docume
10921092
*/
10931093
public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document
10941094
{
1095+
$spatialAttributes = $this->getSpatialAttributes($collection);
10951096
$collection = $collection->getId();
10961097
$attributes = $document->getAttributes();
10971098
$attributes['_createdAt'] = $document->getCreatedAt();
@@ -1252,10 +1253,14 @@ public function updateDocument(Document $collection, string $id, Document $docum
12521253
foreach ($attributes as $attribute => $value) {
12531254
$column = $this->filter($attribute);
12541255

1255-
// Check if this is an operator or regular attribute
1256+
// Check if this is an operator, spatial attribute, or regular attribute
12561257
if (isset($operators[$attribute])) {
12571258
$operatorSQL = $this->getOperatorSQL($column, $operators[$attribute], $bindIndex);
12581259
$columns .= $operatorSQL . ',';
1260+
} elseif (\in_array($attribute, $spatialAttributes, true)) {
1261+
$bindKey = 'key_' . $bindIndex;
1262+
$columns .= "\"{$column}\" = " . $this->getSpatialGeomFromText(':' . $bindKey) . ',';
1263+
$bindIndex++;
12591264
} else {
12601265
$bindKey = 'key_' . $bindIndex;
12611266
$columns .= "\"{$column}\"" . '=:' . $bindKey . ',';
@@ -1287,7 +1292,12 @@ public function updateDocument(Document $collection, string $id, Document $docum
12871292
if (isset($operators[$attribute])) {
12881293
$this->bindOperatorParams($stmt, $operators[$attribute], $attributeIndex);
12891294
} else {
1290-
if (is_array($value)) {
1295+
// Convert spatial arrays to WKT, json_encode non-spatial arrays
1296+
if (\in_array($attribute, $spatialAttributes, true)) {
1297+
if (\is_array($value)) {
1298+
$value = $this->convertArrayToWKT($value);
1299+
}
1300+
} elseif (is_array($value)) {
12911301
$value = json_encode($value);
12921302
}
12931303

@@ -2435,7 +2445,8 @@ protected function getOperatorSQL(string $column, Operator $operator, int &$bind
24352445
$bindIndex++;
24362446
return "{$quotedColumn} = CASE
24372447
WHEN COALESCE({$columnRef}, 0) >= CAST(:$maxKey AS NUMERIC) THEN CAST(:$maxKey AS NUMERIC)
2438-
WHEN CAST(:$bindKey AS NUMERIC) != 0 AND COALESCE({$columnRef}, 0) > CAST(:$maxKey AS NUMERIC) / CAST(:$bindKey AS NUMERIC) THEN CAST(:$maxKey AS NUMERIC)
2448+
WHEN CAST(:$bindKey AS NUMERIC) > 0 AND COALESCE({$columnRef}, 0) > CAST(:$maxKey AS NUMERIC) / CAST(:$bindKey AS NUMERIC) THEN CAST(:$maxKey AS NUMERIC)
2449+
WHEN CAST(:$bindKey AS NUMERIC) < 0 AND COALESCE({$columnRef}, 0) < CAST(:$maxKey AS NUMERIC) / CAST(:$bindKey AS NUMERIC) THEN CAST(:$maxKey AS NUMERIC)
24392450
ELSE COALESCE({$columnRef}, 0) * CAST(:$bindKey AS NUMERIC)
24402451
END";
24412452
}
@@ -2449,7 +2460,8 @@ protected function getOperatorSQL(string $column, Operator $operator, int &$bind
24492460
$bindIndex++;
24502461
return "{$quotedColumn} = CASE
24512462
WHEN COALESCE({$columnRef}, 0) <= CAST(:$minKey AS NUMERIC) THEN CAST(:$minKey AS NUMERIC)
2452-
WHEN CAST(:$bindKey AS NUMERIC) != 0 AND COALESCE({$columnRef}, 0) < CAST(:$minKey AS NUMERIC) * CAST(:$bindKey AS NUMERIC) THEN CAST(:$minKey AS NUMERIC)
2463+
WHEN CAST(:$bindKey AS NUMERIC) > 0 AND COALESCE({$columnRef}, 0) < CAST(:$minKey AS NUMERIC) * CAST(:$bindKey AS NUMERIC) THEN CAST(:$minKey AS NUMERIC)
2464+
WHEN CAST(:$bindKey AS NUMERIC) < 0 AND COALESCE({$columnRef}, 0) > CAST(:$minKey AS NUMERIC) * CAST(:$bindKey AS NUMERIC) THEN CAST(:$minKey AS NUMERIC)
24532465
ELSE COALESCE({$columnRef}, 0) / CAST(:$bindKey AS NUMERIC)
24542466
END";
24552467
}

src/Database/Adapter/SQLite.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,7 @@ public function createDocument(Document $collection, Document $document): Docume
642642
*/
643643
public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document
644644
{
645+
$spatialAttributes = $this->getSpatialAttributes($collection);
645646
$collection = $collection->getId();
646647
$attributes = $document->getAttributes();
647648
$attributes['_createdAt'] = $document->getCreatedAt();
@@ -805,10 +806,14 @@ public function updateDocument(Document $collection, string $id, Document $docum
805806
foreach ($attributes as $attribute => $value) {
806807
$column = $this->filter($attribute);
807808

808-
// Check if this is an operator or regular attribute
809+
// Check if this is an operator, spatial attribute, or regular attribute
809810
if (isset($operators[$attribute])) {
810811
$operatorSQL = $this->getOperatorSQL($column, $operators[$attribute], $bindIndex);
811812
$columns .= $operatorSQL;
813+
} elseif (\in_array($attribute, $spatialAttributes, true)) {
814+
$bindKey = 'key_' . $bindIndex;
815+
$columns .= "`{$column}` = " . $this->getSpatialGeomFromText(':' . $bindKey);
816+
$bindIndex++;
812817
} else {
813818
$bindKey = 'key_' . $bindIndex;
814819
$columns .= "`{$column}`" . '=:' . $bindKey;
@@ -848,7 +853,12 @@ public function updateDocument(Document $collection, string $id, Document $docum
848853
continue;
849854
}
850855

851-
if (is_array($value)) { // arrays & objects should be saved as strings
856+
// Convert spatial arrays to WKT, json_encode non-spatial arrays
857+
if (\in_array($attribute, $spatialAttributes, true)) {
858+
if (\is_array($value)) {
859+
$value = $this->convertArrayToWKT($value);
860+
}
861+
} elseif (is_array($value)) { // arrays & objects should be saved as strings
852862
$value = json_encode($value);
853863
}
854864

src/Database/Database.php

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5336,13 +5336,9 @@ public function updateDocuments(
53365336
}
53375337

53385338
foreach ($batch as $index => $doc) {
5339-
if (!empty($operators)) {
5340-
// Already refetched and decoded
5341-
} else {
5342-
$doc->removeAttribute('$skipPermissionsUpdate');
5343-
$this->purgeCachedDocument($collection->getId(), $doc->getId());
5344-
$doc = $this->decode($collection, $doc);
5345-
}
5339+
$doc->removeAttribute('$skipPermissionsUpdate');
5340+
$this->purgeCachedDocument($collection->getId(), $doc->getId());
5341+
$doc = $this->decode($collection, $doc);
53465342
try {
53475343
$onNext && $onNext($doc, $old[$index]);
53485344
} catch (Throwable $th) {

tests/e2e/Adapter/Scopes/OperatorTests.php

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2255,6 +2255,146 @@ public function testOperatorMultiplyViolatesRange(): void
22552255
$database->deleteCollection($collectionId);
22562256
}
22572257

2258+
/**
2259+
* Test MULTIPLY operator with negative multipliers and max limit
2260+
* Tests: Negative multipliers should not trigger incorrect overflow checks
2261+
*/
2262+
public function testOperatorMultiplyWithNegativeMultiplier(): void
2263+
{
2264+
/** @var Database $database */
2265+
$database = static::getDatabase();
2266+
2267+
$collectionId = 'test_multiply_negative';
2268+
$database->createCollection($collectionId);
2269+
$database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, false);
2270+
2271+
// Test negative multiplier without max limit
2272+
$doc1 = $database->createDocument($collectionId, new Document([
2273+
'$id' => 'negative_multiply',
2274+
'$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())],
2275+
'value' => 10.0
2276+
]));
2277+
2278+
$updated1 = $database->updateDocument($collectionId, 'negative_multiply', new Document([
2279+
'value' => Operator::multiply(-2)
2280+
]));
2281+
$this->assertEquals(-20.0, $updated1->getAttribute('value'), 'Multiply by negative should work correctly');
2282+
2283+
// Test negative multiplier WITH max limit - should not incorrectly cap
2284+
$doc2 = $database->createDocument($collectionId, new Document([
2285+
'$id' => 'negative_with_max',
2286+
'$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())],
2287+
'value' => 10.0
2288+
]));
2289+
2290+
$updated2 = $database->updateDocument($collectionId, 'negative_with_max', new Document([
2291+
'value' => Operator::multiply(-2, 100) // max=100, but result will be -20
2292+
]));
2293+
$this->assertEquals(-20.0, $updated2->getAttribute('value'), 'Negative multiplier with max should not trigger overflow check');
2294+
2295+
// Test positive value * negative multiplier - result is negative, should not cap
2296+
$doc3 = $database->createDocument($collectionId, new Document([
2297+
'$id' => 'pos_times_neg',
2298+
'$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())],
2299+
'value' => 50.0
2300+
]));
2301+
2302+
$updated3 = $database->updateDocument($collectionId, 'pos_times_neg', new Document([
2303+
'value' => Operator::multiply(-3, 100) // 50 * -3 = -150, should not be capped at 100
2304+
]));
2305+
$this->assertEquals(-150.0, $updated3->getAttribute('value'), 'Positive * negative should compute correctly (result is negative, no cap)');
2306+
2307+
// Test negative value * negative multiplier that SHOULD hit max cap
2308+
$doc4 = $database->createDocument($collectionId, new Document([
2309+
'$id' => 'negative_overflow',
2310+
'$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())],
2311+
'value' => -60.0
2312+
]));
2313+
2314+
$updated4 = $database->updateDocument($collectionId, 'negative_overflow', new Document([
2315+
'value' => Operator::multiply(-3, 100) // -60 * -3 = 180, should be capped at 100
2316+
]));
2317+
$this->assertEquals(100.0, $updated4->getAttribute('value'), 'Negative * negative should cap at max when result would exceed it');
2318+
2319+
// Test zero multiplier with max
2320+
$doc5 = $database->createDocument($collectionId, new Document([
2321+
'$id' => 'zero_multiply',
2322+
'$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())],
2323+
'value' => 50.0
2324+
]));
2325+
2326+
$updated5 = $database->updateDocument($collectionId, 'zero_multiply', new Document([
2327+
'value' => Operator::multiply(0, 100)
2328+
]));
2329+
$this->assertEquals(0.0, $updated5->getAttribute('value'), 'Multiply by zero should result in zero');
2330+
2331+
$database->deleteCollection($collectionId);
2332+
}
2333+
2334+
/**
2335+
* Test DIVIDE operator with negative divisors and min limit
2336+
* Tests: Negative divisors should not trigger incorrect underflow checks
2337+
*/
2338+
public function testOperatorDivideWithNegativeDivisor(): void
2339+
{
2340+
/** @var Database $database */
2341+
$database = static::getDatabase();
2342+
2343+
$collectionId = 'test_divide_negative';
2344+
$database->createCollection($collectionId);
2345+
$database->createAttribute($collectionId, 'value', Database::VAR_FLOAT, 0, false);
2346+
2347+
// Test negative divisor without min limit
2348+
$doc1 = $database->createDocument($collectionId, new Document([
2349+
'$id' => 'negative_divide',
2350+
'$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())],
2351+
'value' => 20.0
2352+
]));
2353+
2354+
$updated1 = $database->updateDocument($collectionId, 'negative_divide', new Document([
2355+
'value' => Operator::divide(-2)
2356+
]));
2357+
$this->assertEquals(-10.0, $updated1->getAttribute('value'), 'Divide by negative should work correctly');
2358+
2359+
// Test negative divisor WITH min limit - should not incorrectly cap
2360+
$doc2 = $database->createDocument($collectionId, new Document([
2361+
'$id' => 'negative_with_min',
2362+
'$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())],
2363+
'value' => 20.0
2364+
]));
2365+
2366+
$updated2 = $database->updateDocument($collectionId, 'negative_with_min', new Document([
2367+
'value' => Operator::divide(-2, -50) // min=-50, result will be -10
2368+
]));
2369+
$this->assertEquals(-10.0, $updated2->getAttribute('value'), 'Negative divisor with min should not trigger underflow check');
2370+
2371+
// Test positive value / negative divisor - result is negative, should not cap at min
2372+
$doc3 = $database->createDocument($collectionId, new Document([
2373+
'$id' => 'pos_div_neg',
2374+
'$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())],
2375+
'value' => 100.0
2376+
]));
2377+
2378+
$updated3 = $database->updateDocument($collectionId, 'pos_div_neg', new Document([
2379+
'value' => Operator::divide(-4, -10) // 100 / -4 = -25, should not be capped at -10
2380+
]));
2381+
$this->assertEquals(-25.0, $updated3->getAttribute('value'), 'Positive / negative should compute correctly (result is negative, no cap)');
2382+
2383+
// Test negative value / negative divisor that would go below min
2384+
$doc4 = $database->createDocument($collectionId, new Document([
2385+
'$id' => 'negative_underflow',
2386+
'$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())],
2387+
'value' => 40.0
2388+
]));
2389+
2390+
$updated4 = $database->updateDocument($collectionId, 'negative_underflow', new Document([
2391+
'value' => Operator::divide(-2, -10) // 40 / -2 = -20, which is below min -10, so floor at -10
2392+
]));
2393+
$this->assertEquals(-10.0, $updated4->getAttribute('value'), 'Positive / negative should floor at min when result would be below it');
2394+
2395+
$database->deleteCollection($collectionId);
2396+
}
2397+
22582398
/**
22592399
* Bug #6: Post-Operator Validation Missing
22602400
* Test that ARRAY_APPEND can add items that violate array item constraints
@@ -3606,4 +3746,114 @@ public function testUpsertOperatorsOnNewDocuments(): void
36063746
// Cleanup
36073747
$database->deleteCollection($collectionId);
36083748
}
3749+
3750+
/**
3751+
* Test that array operators return empty arrays instead of NULL
3752+
* Tests: ARRAY_UNIQUE, ARRAY_INTERSECT, and ARRAY_DIFF return [] not NULL
3753+
*/
3754+
public function testOperatorArrayEmptyResultsNotNull(): void
3755+
{
3756+
/** @var Database $database */
3757+
$database = static::getDatabase();
3758+
3759+
$collectionId = 'test_array_not_null';
3760+
$database->createCollection($collectionId);
3761+
$database->createAttribute($collectionId, 'items', Database::VAR_STRING, 50, false, null, true, true);
3762+
3763+
// Test ARRAY_UNIQUE on empty array returns [] not NULL
3764+
$doc1 = $database->createDocument($collectionId, new Document([
3765+
'$id' => 'empty_unique',
3766+
'$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())],
3767+
'items' => []
3768+
]));
3769+
3770+
$updated1 = $database->updateDocument($collectionId, 'empty_unique', new Document([
3771+
'items' => Operator::arrayUnique()
3772+
]));
3773+
$this->assertIsArray($updated1->getAttribute('items'), 'ARRAY_UNIQUE should return array not NULL');
3774+
$this->assertEquals([], $updated1->getAttribute('items'), 'ARRAY_UNIQUE on empty array should return []');
3775+
3776+
// Test ARRAY_INTERSECT with no matches returns [] not NULL
3777+
$doc2 = $database->createDocument($collectionId, new Document([
3778+
'$id' => 'no_intersect',
3779+
'$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())],
3780+
'items' => ['a', 'b', 'c']
3781+
]));
3782+
3783+
$updated2 = $database->updateDocument($collectionId, 'no_intersect', new Document([
3784+
'items' => Operator::arrayIntersect(['x', 'y', 'z'])
3785+
]));
3786+
$this->assertIsArray($updated2->getAttribute('items'), 'ARRAY_INTERSECT should return array not NULL');
3787+
$this->assertEquals([], $updated2->getAttribute('items'), 'ARRAY_INTERSECT with no matches should return []');
3788+
3789+
// Test ARRAY_DIFF removing all elements returns [] not NULL
3790+
$doc3 = $database->createDocument($collectionId, new Document([
3791+
'$id' => 'diff_all',
3792+
'$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())],
3793+
'items' => ['a', 'b', 'c']
3794+
]));
3795+
3796+
$updated3 = $database->updateDocument($collectionId, 'diff_all', new Document([
3797+
'items' => Operator::arrayDiff(['a', 'b', 'c'])
3798+
]));
3799+
$this->assertIsArray($updated3->getAttribute('items'), 'ARRAY_DIFF should return array not NULL');
3800+
$this->assertEquals([], $updated3->getAttribute('items'), 'ARRAY_DIFF removing all elements should return []');
3801+
3802+
// Cleanup
3803+
$database->deleteCollection($collectionId);
3804+
}
3805+
3806+
/**
3807+
* Test that updateDocuments with operators properly invalidates cache
3808+
* Tests: Cache should be purged after operator updates to prevent stale data
3809+
*/
3810+
public function testUpdateDocumentsWithOperatorsCacheInvalidation(): void
3811+
{
3812+
/** @var Database $database */
3813+
$database = static::getDatabase();
3814+
3815+
$collectionId = 'test_operator_cache';
3816+
$database->createCollection($collectionId);
3817+
$database->createAttribute($collectionId, 'counter', Database::VAR_INTEGER, 0, false, 0);
3818+
3819+
// Create a document
3820+
$doc = $database->createDocument($collectionId, new Document([
3821+
'$id' => 'cache_test',
3822+
'$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())],
3823+
'counter' => 10
3824+
]));
3825+
3826+
// First read to potentially cache
3827+
$fetched1 = $database->getDocument($collectionId, 'cache_test');
3828+
$this->assertEquals(10, $fetched1->getAttribute('counter'));
3829+
3830+
// Use updateDocuments with operator
3831+
$count = $database->updateDocuments(
3832+
$collectionId,
3833+
new Document([
3834+
'counter' => Operator::increment(5)
3835+
]),
3836+
[Query::equal('$id', ['cache_test'])]
3837+
);
3838+
3839+
$this->assertEquals(1, $count);
3840+
3841+
// Read again - should get fresh value, not cached old value
3842+
$fetched2 = $database->getDocument($collectionId, 'cache_test');
3843+
$this->assertEquals(15, $fetched2->getAttribute('counter'), 'Cache should be invalidated after operator update');
3844+
3845+
// Do another operator update
3846+
$database->updateDocuments(
3847+
$collectionId,
3848+
new Document([
3849+
'counter' => Operator::multiply(2)
3850+
])
3851+
);
3852+
3853+
// Verify cache was invalidated again
3854+
$fetched3 = $database->getDocument($collectionId, 'cache_test');
3855+
$this->assertEquals(30, $fetched3->getAttribute('counter'), 'Cache should be invalidated after second operator update');
3856+
3857+
$database->deleteCollection($collectionId);
3858+
}
36093859
}

0 commit comments

Comments
 (0)