Repository: phalcon/phalcon
Pull request #626: Trigger db commit event after event commit
Author: login: GrzegorzDrozd
Created: 2025-05-14T14:26:42Z | Merged: 2025-05-19T21:30:25Z

Hello!

*  Type:new feature

**In raising this pull request, I confirm the following:**

-  [ ] I have read and understood the [Contributing Guidelines](https://github.com/phalcon/phalcon/blob/master/CONTRIBUTING.md)
-  [ ] I have checked that another pull request for this purpose does not exist
-  [ ] I wrote some tests for this PR
-  [ ] I have updated the relevant CHANGELOG
-  [ ] I have created a PR for the [documentation](https://github.com/phalcon/docs) about this change

Small description of change:

Notify the events manager when a transaction is committed or a savepoint is released. This enhances observability and allows external systems to react to these database operations.

Thanks



Diff:
diff --git a/src/Db/Adapter/Pdo/AbstractPdo.php b/src/Db/Adapter/Pdo/AbstractPdo.php
index e3883bce56..f08ad4c105 100644
--- a/src/Db/Adapter/Pdo/AbstractPdo.php
+++ b/src/Db/Adapter/Pdo/AbstractPdo.php
@@ -198,7 +198,22 @@ public function commit(bool $nesting = true): bool
              */
             $this->transactionLevel--;
 
-            return $this->pdo->commit();
+            $result = $this->pdo->commit();
+
+            /**
+             * When error mode is set to silent or warning, we need to check result and trigger event only when
+             * $result is not false.
+             */
+            if ($result === false) {
+                return false;
+            }
+
+            /**
+             * Notify the events manager about the committed transaction
+             */
+            $this->fireManagerEvent('db:transactionCommitted');
+
+            return true;
         }
 
         /**
@@ -229,8 +244,22 @@ public function commit(bool $nesting = true): bool
          * Reduce the transaction nesting level
          */
         $this->transactionLevel--;
+        $result = $this->releaseSavepoint($savepointName);
 
-        return $this->releaseSavepoint($savepointName);
+        /**
+         * When error mode is set to silent or warning, we need to check result and trigger event only when
+         * $result is not false.
+         */
+        if ($result === false) {
+            return false;
+        }
+
+        /**
+         * Notify the events manager about the released savepoint
+         */
+        $this->fireManagerEvent('db:savepointReleased', $savepointName);
+
+        return true;
     }
 
     /**
@@ -795,7 +824,11 @@ public function rollback(bool $nesting = true): bool
              */
             $this->transactionLevel--;
 
-            return $this->pdo->rollback();
+            $result = $this->pdo->rollback();
+
+            $this->fireManagerEvent('db:transactionRolledBack');
+
+            return $result;
         }
 
         /**
diff --git a/tests/database/Db/Adapter/Pdo/EventsTest.php b/tests/database/Db/Adapter/Pdo/EventsTest.php
new file mode 100644
index 0000000000..62cc01fae7
--- /dev/null
+++ b/tests/database/Db/Adapter/Pdo/EventsTest.php
@@ -0,0 +1,153 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Phalcon\Tests\Database\Db\Adapter\Pdo;
+
+use PDO;
+use Phalcon\Db\Adapter\Pdo\Sqlite;
+use Phalcon\Events\Manager;
+use Phalcon\Tests\Unit\AbstractUnitTestCase;
+
+final class EventsTest extends AbstractUnitTestCase
+{
+    public function eventsProvider(): array
+    {
+        return [
+            ['SELECT 1', 'query', ['beforeQuery', 'afterQuery']],
+            ['CREATE TABLE test (id INT)', 'execute', ['beforeQuery', 'afterQuery']],
+            ['', 'commit', ['beginTransaction', 'commitTransaction', 'transactionCommitted']],
+            ['', 'rollback', ['beginTransaction', 'rollbackTransaction', 'transactionRolledBack']],
+        ];
+    }
+
+    /**
+     * @dataProvider eventsProvider
+     */
+    public function testEvents($sql, $event, array $expectedEvents = []): void
+    {
+        $connection = new Sqlite([
+            'dbname' => ':memory:',
+        ]);
+
+        $manager = new Manager();
+        $connection->setEventsManager($manager);
+
+        $listener = new class {
+            public array $events = [];
+
+            public function beforeQuery($event, $adapter, mixed $data = null)
+            {
+                $this->events[] = __FUNCTION__;
+            }
+
+            public function afterQuery($event, $adapter, mixed $data = null)
+            {
+                $this->events[] = __FUNCTION__;
+            }
+
+            public function commitTransaction($event, $adapter, mixed $data = null)
+            {
+                $this->events[] = __FUNCTION__;
+            }
+
+            public function beginTransaction($event, $adapter, mixed $data = null)
+            {
+                $this->events[] = __FUNCTION__;
+            }
+
+            public function transactionCommitted($event, $adapter, mixed $data = null)
+            {
+                $this->events[] = __FUNCTION__;
+            }
+
+            public function rollbackTransaction($event, $adapter, mixed $data = null)
+            {
+                $this->events[] = __FUNCTION__;
+            }
+
+            public function transactionRolledBack($event, $adapter, mixed $data = null)
+            {
+                $this->events[] = __FUNCTION__;
+            }
+        };
+
+        $manager->attach('db', $listener);
+
+        // Execute the SQL and check events based on the example data
+        switch ($event) {
+            case 'query':
+                $connection->query($sql);
+                break;
+
+            case 'execute':
+                $connection->execute($sql);
+                break;
+
+            case 'commit':
+                $connection->begin();
+                $connection->execute(
+                    'CREATE TABLE test (id INT)'
+                );
+                $connection->commit();
+                break;
+
+            case 'rollback':
+                $connection->begin();
+                $connection->execute(
+                    'CREATE TABLE test (id INT)'
+                ); // Needs a query inside transaction for rollback to trigger
+                $connection->rollback();
+                break;
+        }
+
+        foreach ($expectedEvents as $expectedEvent) {
+            $this->assertContains($expectedEvent, $listener->events);
+        }
+    }
+
+    public function testBadCommit(): void
+    {
+        $mockPDO = $this->getMockBuilder(PDO::class)
+            ->disableOriginalConstructor()
+            ->onlyMethods(['commit'])
+            ->getMock();
+
+        $mockPDO->expects($this->once())->method('commit')->willReturn(false);
+
+        $connection = new Sqlite([
+            'dbname' => ':memory:',
+        ]);
+        $ref = new \ReflectionObject($connection);
+
+        $property = $ref->getProperty('pdo');
+        $property->setAccessible(true);
+        $property->setValue($connection, $mockPDO);
+
+        $property = $ref->getProperty('transactionLevel');
+        $property->setAccessible(true);
+        $property->setValue($connection, 1);
+
+        $manager = new Manager();
+        $connection->setEventsManager($manager);
+
+        $listener = new class {
+            public array $events = [];
+
+            public function commitTransaction($event, $adapter, mixed $data = null)
+            {
+                $this->events[] = __FUNCTION__;
+            }
+            public function transactionCommitted($event, $adapter, mixed $data = null)
+            {
+                $this->events[] = __FUNCTION__;
+            }
+        };
+
+        $manager->attach('db', $listener);
+
+        $connection->commit();
+        $this->assertContains('commitTransaction', $listener->events);
+        $this->assertNotContains('transactionCommitted', $listener->events);
+    }
+}


Reviews:
user: login: noone-silent
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T04:21:20Z
user: login: noone-silent
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T04:21:47Z
user: login: noone-silent
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T23:08:43Z
user: login: niden
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-18T13:54:17Z
user: login: GrzegorzDrozd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-18T15:18:55Z
user: login: niden
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-19T21:30:11Z

Review comments:
diff_hunk: @@ -198,7 +198,13 @@ public function commit(bool $nesting = true): bool
              */
             $this->transactionLevel--;
 
-            return $this->pdo->commit();
+            $result = $this->pdo->commit();
+            /**
+             * Notify the events manager about the committed transaction
+             */
+            $this->fireManagerEvent('db:transactionCommited');
path: src/Db/Adapter/Pdo/AbstractPdo.php
user: login: noone-silent
body: Should be `"db:transactionCommitted"`
created_at: 2025-05-15T04:21:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -229,8 +235,14 @@ public function commit(bool $nesting = true): bool
          * Reduce the transaction nesting level
          */
         $this->transactionLevel--;
+        $result = $this->releaseSavepoint($savepointName);
+
+        /**
+         * Notify the events manager about the committed savepoint
path: src/Db/Adapter/Pdo/AbstractPdo.php
user: login: noone-silent
body: `Notify the events manager about the released savepoint`
created_at: 2025-05-15T04:21:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -198,7 +198,22 @@ public function commit(bool $nesting = true): bool
              */
             $this->transactionLevel--;
 
-            return $this->pdo->commit();
+            $result = $this->pdo->commit();
+
+            /**
+             * When error mode is set to silent or warning, we need to check result and trigger event only when
path: src/Db/Adapter/Pdo/AbstractPdo.php
user: login: niden
body: This is the "silent" mode I refer in my comment
created_at: 2025-05-18T13:54:08Z
author_association: MEMBER
diff_hunk: @@ -198,7 +198,22 @@ public function commit(bool $nesting = true): bool
              */
             $this->transactionLevel--;
 
-            return $this->pdo->commit();
+            $result = $this->pdo->commit();
+
+            /**
+             * When error mode is set to silent or warning, we need to check result and trigger event only when
path: src/Db/Adapter/Pdo/AbstractPdo.php
user: login: GrzegorzDrozd
body: Yes, it is about `PDO::ATTR_ERRMODE` value that can be one of those three:

1. PDO::ERRMODE_EXCEPTION - we don't need to check anything, execution will stop
2. PDO::ERRMODE_WARNING - warning will be generated and called pdo method will return false
3. PDO::ERRMODE_SILENT - there will be no warning but called pdo method will return false.

So in cases 2 and 3 we just need to check return from the method. We don't need to know which mode it is.
In case error mode is in exception then event will not be triggered anyway.
created_at: 2025-05-18T15:18:55Z
author_association: CONTRIBUTOR

PR comments:
user: login: Jeckerson
created_at: 2025-05-15T23:31:56Z
body: Nice, would be great to simulate with tests.
author_association: MEMBER
user: login: GrzegorzDrozd
created_at: 2025-05-16T11:36:00Z
body: @niden Hi, I see your https://github.com/phalcon/phalcon/pull/628 but I also made one with some additional changes. Can you take a look ?
author_association: CONTRIBUTOR
user: login: niden
created_at: 2025-05-18T13:07:35Z
body: @GrzegorzDrozd 

This looks good and complements #628 which I will close.

- Please rebase with the current v6.0.0 because the actions do not run without that change.
- You have removed the `commitTransaction` event and replaced it with `committedTransaction`. This will most likely break some apps that utilize the former. Please add the `commitTransaction` as it were and we can have the `committedTransaction` as the final result. Doing so will increase the events triggered but it will allow for an easier transition to v6. Later on we can remove the `commitTransaction`. The same of course for the `rollback`.
- Finally, you mention about a "silent mode" during commit in your comment. How is that detected? By silent mode I assume you mean the setting of `PDO::ATTR_ERRMODE`. If my assumption is correct then you are not checking it there.
author_association: MEMBER
user: login: GrzegorzDrozd
created_at: 2025-05-18T15:23:54Z
body: Hi @niden ,

> Please rebase with the current v6.0.0 because the actions do not run without that change.

OK, will do asap.

> You have removed the `commitTransaction` event and replaced it with `committedTransaction`. This will most likely break some apps that utilize the former. Please add the `commitTransaction` as it were and we can have the `committedTransaction` as the final result. Doing so will increase the events triggered but it will allow for an easier transition to v6. Later on we can remove the `commitTransaction`. The same of course for the `rollback`.

commitTransaction is still there in line 194. I have not removed it. I also did not removed `releaseSavepoint` from line 241 and `rollbackTransaction` from 820. I added new events for that reason - to not break compatibility. I also think that those should be eventually deprecated.

> Finally, you mention about a "silent mode" during commit in your comment. How is that detected? By silent mode I assume you mean the setting of `PDO::ATTR_ERRMODE`. If my assumption is correct then you are not checking it there.

Explained in my other comment. There is no need to read error mode, we just need to check response.


author_association: CONTRIBUTOR
user: login: niden
created_at: 2025-05-19T13:05:00Z
body: @GrzegorzDrozd Thanks for the explanations. 

This looks good once the tests pass. You will need to run `composer cs-fix` to fix the issue with the test (see run) or just fix it manually.
author_association: MEMBER
user: login: GrzegorzDrozd
created_at: 2025-05-19T19:14:58Z
body: @niden I am not sure what happened but I synced my fork (using github) then:
```shell
> git fetch origin
> git rebase refs/heads/v6.0.x
Rebasing (1/5)
Rebasing (2/5)
Rebasing (3/5)
Rebasing (4/5)
Rebasing (5/5)
Successfully rebased and updated refs/heads/trigger-db-commit-event-after-event-commit.
> git fetch origin
> git merge origin/trigger-db-commit-event-after-event-commit
> git add tests/database/Db/Adapter/Pdo/EventsTest.php
> git commit 
```
I am not that fluent with rebasing :/ Not sure why it shows all those files are changed...
(i used phpstorm for those commands).
author_association: CONTRIBUTOR
user: login: niden
created_at: 2025-05-19T21:31:03Z
body: Solid work. Thank you @GrzegorzDrozd 

I rebased your branch and force pushed upstream and that solved the issue you were looking at with many files vs. 2.
author_association: MEMBER