Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 58
Fix ghost perms#813
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Fix ghost perms #813
Changes from all commits
33ed565b8542f640080e29523b2aa375c36File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -3483,12 +3483,11 @@ protected function processException(\Throwable $e): \Throwable | ||
| } | ||
| // Duplicate key error | ||
| if ($e->getCode() === 11000) { | ||
| return new DuplicateException('Document already exists', $e->getCode(), $e); | ||
| } | ||
| // Duplicate key error for unique index | ||
| if ($e->getCode() === 11001) { | ||
| if ($e->getCode() === 11000 || $e->getCode() === 11001) { | ||
| $message = $e->getMessage(); | ||
| if (!\str_contains($message, '_uid')) { | ||
| return new DuplicateException('Document with the requested unique attributes already exists', $e->getCode(), $e); | ||
| } | ||
| return new DuplicateException('Document already exists', $e->getCode(), $e); | ||
abnegate marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -16,6 +16,12 @@ class Pool extends Adapter | ||
| */ | ||
| protected UtopiaPool $pool; | ||
| /** | ||
| * When a transaction is active, all delegate calls are routed through | ||
| * this pinned adapter to ensure they run on the same connection. | ||
| */ | ||
| protected ?Adapter $pinnedAdapter = null; | ||
| /** | ||
| * @param UtopiaPool<covariant Adapter> $pool The pool to use for connections. Must contain instances of Adapter. | ||
| */ | ||
| @@ -36,6 +42,10 @@ public function __construct(UtopiaPool $pool) | ||
| */ | ||
| public function delegate(string $method, array $args): mixed | ||
| { | ||
| if ($this->pinnedAdapter !== null) { | ||
| return $this->pinnedAdapter->{$method}(...$args); | ||
| } | ||
| return $this->pool->use(function (Adapter $adapter) use ($method, $args) { | ||
| // Run setters in case config changed since this connection was last used | ||
| $adapter->setDatabase($this->getDatabase()); | ||
| @@ -92,6 +102,52 @@ public function rollbackTransaction(): bool | ||
| return $this->delegate(__FUNCTION__, \func_get_args()); | ||
| } | ||
| /** | ||
| * Pin a single connection from the pool for the entire transaction lifecycle. | ||
| * This prevents startTransaction(), the callback, and commitTransaction() | ||
| * from running on different connections. | ||
| * | ||
| * @template T | ||
| * @param callable(): T $callback | ||
| * @return T | ||
| * @throws \Throwable | ||
| */ | ||
| public function withTransaction(callable $callback): mixed | ||
| { | ||
| // If already inside a transaction, reuse the pinned adapter | ||
| // so nested withTransaction calls use the same connection | ||
| if ($this->pinnedAdapter !== null) { | ||
| return $this->pinnedAdapter->withTransaction($callback); | ||
| } | ||
| return $this->pool->use(function (Adapter $adapter) use ($callback) { | ||
| $adapter->setDatabase($this->getDatabase()); | ||
| $adapter->setNamespace($this->getNamespace()); | ||
| $adapter->setSharedTables($this->getSharedTables()); | ||
| $adapter->setTenant($this->getTenant()); | ||
| $adapter->setAuthorization($this->authorization); | ||
| if ($this->getTimeout() > 0) { | ||
| $adapter->setTimeout($this->getTimeout()); | ||
| } | ||
| $adapter->resetDebug(); | ||
| foreach ($this->getDebug() as $key => $value) { | ||
| $adapter->setDebug($key, $value); | ||
| } | ||
| $adapter->resetMetadata(); | ||
| foreach ($this->getMetadata() as $key => $value) { | ||
| $adapter->setMetadata($key, $value); | ||
| } | ||
| $this->pinnedAdapter = $adapter; | ||
| try { | ||
| return $adapter->withTransaction($callback); | ||
| } finally { | ||
| $this->pinnedAdapter = null; | ||
| } | ||
| }); | ||
| } | ||
Comment on lines
+115
to
+149
Contributor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash# Check how Pool adapter instances are created/shared in the codebaseecho"=== Pool instantiation patterns ==="
rg -n 'new Pool\(' --type php -g '!vendor/**' -g '!tests/**'| head -100
echo""echo"=== Pool::class bindings in DI containers ==="
rg -n 'Pool::class' --type php -g '!vendor/**' -g '!tests/**'| head -100
echo""echo"=== Check for singleton/shared container bindings ==="
rg -n -B2 -A2 '(singleton|share|shared).*[Pp]ool' --type php -g '!vendor/**' -g '!tests/**'| head -100Repository: utopia-php/database Length of output: 200 🏁 Script executed: #!/bin/bash# Check for Pool usage patterns (imports, instanceof, etc.)echo"=== Import/use Pool patterns ==="
rg -n '(use.*Pool|instanceof Pool)' --type php -g '!vendor/**'| head -100
echo""echo"=== Pool class definition and interface ==="
rg -n 'class Pool\|interface.*Pool' --type php -g '!vendor/**'| head -50
echo""echo"=== Check test files for Pool usage ==="
rg -n 'new Pool\|Pool::class' --type php tests/ 2>/dev/null | head -100
echo""echo"=== Check if Pool is created through a factory or builder ==="
rg -n -B3 -A3 'function.*[Pp]ool|Pool.*factory' --type php -g '!vendor/**'| head -150Repository: utopia-php/database Length of output: 2067 🏁 Script executed: #!/bin/bash# Read the Pool test file to understand usage patternsecho"=== Pool Test File ==="
cat -n tests/e2e/Adapter/PoolTest.php
echo""echo"=== Search for pool->use() patterns ==="
rg -n -B2 -A5 'pool->use\(' --type php -g '!vendor/**'| head -150
echo""echo"=== Check for Fiber/async documentation or comments ==="
rg -n -i '(fiber|coroutine|concurrent|async|swoole|thread)' --type php src/Database/Adapter/Pool.phpRepository: utopia-php/database Length of output: 11805 🏁 Script executed: #!/bin/bash# Check for Database instantiation patterns in production codeecho"=== Database instantiation patterns ==="
rg -n 'new Database\(' --type php src/ | head -100
echo""echo"=== Check Pool class structure and properties ==="
head -100 src/Database/Adapter/Pool.php
echo""echo"=== Look for README or documentation ==="
find . -maxdepth 2 -name 'README*' -o -name '*.md'| head -10Repository: utopia-php/database Length of output: 3362 🏁 Script executed: #!/bin/bash# Check README for Pool usage documentationecho"=== README Pool usage section ==="
grep -n -i -A10 -B2 'pool' README.md | head -100
echo""echo"=== Check SPEC for Pool design ==="
grep -n -i -A10 'pool\|transaction\|concurrent\|coroutine' SPEC.md | head -150Repository: utopia-php/database Length of output: 132 🏁 Script executed: #!/bin/bash# Read full READMEecho"=== Full README.md ==="
cat README.md
echo""echo"=== Search for UtopiaPool usage documentation ==="
rg -n 'UtopiaPool\|Pools\\Pool' --type php -B5 -A5 src/ | head -200Repository: utopia-php/database Length of output: 26019 🏁 Script executed: #!/bin/bash# Check if there are any concurrency-related comments or patternsecho"=== Search for concurrency/thread safety patterns in Pool ==="
cat -n src/Database/Adapter/Pool.php | grep -i -A5 -B5 'thread\|concurrent\|coroutine\|fiber\|async\|race\|mutex\|lock'echo""echo"=== Check Pool's usage of delegate() with pinnedAdapter ==="
cat -n src/Database/Adapter/Pool.php | head -150
echo""echo"=== Search for any container/DI configuration files ==="
find . -maxdepth 3 -name 'config*' -o -name 'container*' -o -name 'services*'| grep -v vendor | grep -v node_modules
echo""echo"=== Check if there are any examples or integration tests showing shared Pool usage ==="
find . -path ./vendor -prune -o -name '*example*' -o -name '*integration*' -type f -print 2>/dev/null | head -20Repository: utopia-php/database Length of output: 7023 Document
While Pool is dependency-injected (suggesting per-request instantiation), the codebase lacks documentation or configuration showing the intended ownership model. Add documentation clarifying whether Pool instances must be instantiated per-request/per-coroutine, or refactor to use context-aware storage (e.g., 🤖 Prompt for AI Agents | ||
| protected function quote(string $string): string | ||
| { | ||
| return $this->delegate(__FUNCTION__, \func_get_args()); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -2189,6 +2189,10 @@ protected function processException(PDOException $e): \Exception | ||
| // Duplicate row | ||
| if ($e->getCode() === '23505' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { | ||
| $message = $e->getMessage(); | ||
| if (!\str_contains($message, '_uid')) { | ||
| return new DuplicateException('Document with the requested unique attributes already exists', $e->getCode(), $e); | ||
| } | ||
| return new DuplicateException('Document already exists', $e->getCode(), $e); | ||
abnegate marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1328,6 +1328,9 @@ protected function processException(PDOException $e): \Exception | ||
| stripos($message, 'unique') !== false || | ||
| stripos($message, 'duplicate') !== false | ||
| ) { | ||
| if (!\str_contains($message, '_uid')) { | ||
| return new DuplicateException('Document with the requested unique attributes already exists', $e->getCode(), $e); | ||
| } | ||
| return new DuplicateException('Document already exists', $e->getCode(), $e); | ||
abnegate marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -5007,6 +5007,64 @@ public function testUniqueIndexDuplicate(): void | ||
| $this->assertInstanceOf(DuplicateException::class, $e); | ||
| } | ||
| } | ||
| /** | ||
| * Test that DuplicateException messages differentiate between | ||
| * document ID duplicates and unique index violations. | ||
| */ | ||
| public function testDuplicateExceptionMessages(): void | ||
| { | ||
| /** @var Database $database */ | ||
| $database = $this->getDatabase(); | ||
| if (!$database->getAdapter()->getSupportForUniqueIndex()) { | ||
| $this->expectNotToPerformAssertions(); | ||
| return; | ||
| } | ||
| $database->createCollection('duplicateMessages'); | ||
| $database->createAttribute('duplicateMessages', 'email', Database::VAR_STRING, 128, true); | ||
| $database->createIndex('duplicateMessages', 'emailUnique', Database::INDEX_UNIQUE, ['email'], [128]); | ||
| // Create first document | ||
| $database->createDocument('duplicateMessages', new Document([ | ||
| '$id' => 'dup_msg_1', | ||
| '$permissions' => [ | ||
| Permission::read(Role::any()), | ||
| ], | ||
| 'email' => 'test@example.com', | ||
| ])); | ||
| // Test 1: Duplicate document ID should say "Document already exists" | ||
| try { | ||
| $database->createDocument('duplicateMessages', new Document([ | ||
| '$id' => 'dup_msg_1', | ||
| '$permissions' => [ | ||
| Permission::read(Role::any()), | ||
| ], | ||
| 'email' => 'different@example.com', | ||
| ])); | ||
| $this->fail('Expected DuplicateException for duplicate document ID'); | ||
| } catch (DuplicateException $e) { | ||
| $this->assertStringContainsString('Document already exists', $e->getMessage()); | ||
| } | ||
| // Test 2: Unique index violation should mention "unique attributes" | ||
| try { | ||
| $database->createDocument('duplicateMessages', new Document([ | ||
| '$id' => 'dup_msg_2', | ||
| '$permissions' => [ | ||
| Permission::read(Role::any()), | ||
| ], | ||
| 'email' => 'test@example.com', | ||
| ])); | ||
| $this->fail('Expected DuplicateException for unique index violation'); | ||
| } catch (DuplicateException $e) { | ||
| $this->assertStringContainsString('unique attributes', $e->getMessage()); | ||
| } | ||
abnegate marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| $database->deleteCollection('duplicateMessages'); | ||
| } | ||
abnegate marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| /** | ||
| * @depends testUniqueIndexDuplicate | ||
| */ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -793,6 +793,58 @@ public function testCacheReconnect(): void | ||
| } | ||
| } | ||
| /** | ||
| * Test that withTransaction properly rolls back on failure. | ||
| * With the Pool adapter, this verifies that the entire transaction | ||
| * (start, callback, commit/rollback) runs on a single pinned connection. | ||
| */ | ||
| public function testTransactionAtomicity(): void | ||
| { | ||
| /** @var Database $database */ | ||
| $database = $this->getDatabase(); | ||
| $database->createCollection('transactionAtomicity'); | ||
| $database->createAttribute('transactionAtomicity', 'title', Database::VAR_STRING, 128, true); | ||
| // Verify a successful transaction commits | ||
| $doc = $database->withTransaction(function () use ($database) { | ||
| return $database->createDocument('transactionAtomicity', new Document([ | ||
| '$id' => 'tx_success', | ||
| '$permissions' => [ | ||
| Permission::read(Role::any()), | ||
| ], | ||
| 'title' => 'Committed', | ||
| ])); | ||
| }); | ||
| $this->assertEquals('tx_success', $doc->getId()); | ||
| $found = $database->getDocument('transactionAtomicity', 'tx_success'); | ||
| $this->assertFalse($found->isEmpty()); | ||
| $this->assertEquals('Committed', $found->getAttribute('title')); | ||
| // Verify a failed transaction rolls back completely | ||
| try { | ||
| $database->withTransaction(function () use ($database) { | ||
| $database->createDocument('transactionAtomicity', new Document([ | ||
| '$id' => 'tx_fail', | ||
| '$permissions' => [ | ||
| Permission::read(Role::any()), | ||
| ], | ||
| 'title' => 'Should be rolled back', | ||
| ])); | ||
| throw new \Exception('Intentional failure to trigger rollback'); | ||
| }); | ||
| } catch (\Exception $e) { | ||
| $this->assertEquals('Intentional failure to trigger rollback', $e->getMessage()); | ||
| } | ||
abnegate marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| // Document should NOT exist since the transaction was rolled back | ||
| $notFound = $database->getDocument('transactionAtomicity', 'tx_fail'); | ||
| $this->assertTrue($notFound->isEmpty(), 'Document should not exist after transaction rollback'); | ||
| $database->deleteCollection('transactionAtomicity'); | ||
| } | ||
| /** | ||
| * Wait for Redis to be ready with a readiness probe | ||
| */ | ||
Uh oh!
There was an error while loading. Please reload this page.