Skip to content

Database Transaction Feature #12

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

Merged
merged 29 commits into from
Aug 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
0fd2d47
Add TransactionException
Apr 28, 2025
29f9543
Add Transaction Builder
Apr 28, 2025
1c43036
Add Transaction class which is invokable
Apr 28, 2025
7d9fc9c
Add transaction method to QueryBuilder
Apr 28, 2025
65fa5de
Add transactionQuery
Apr 30, 2025
9fa867e
Add message to rollback method on TransactionQuery
Apr 30, 2025
add7698
Fix using transactionQuery
Apr 30, 2025
ef3e0b7
Fix Transaction Exception
Apr 30, 2025
e299c6d
Change transaction body type to accept Closure
Apr 30, 2025
2ca8d0b
Add transaction example
Apr 30, 2025
eec7445
Remove Transaction Builder and Query
May 3, 2025
242a01d
Add ReservableConnection
May 3, 2025
7363516
Add queryResult and iterator QueryResultCollection
May 3, 2025
8d92cfb
Add Transaction class
May 3, 2025
3bca8c1
Move transaction class to Clauses folder
May 3, 2025
cb9b6d9
Make DBFactory nullable on Transaction constructor to be able to test
May 3, 2025
154b1b9
Add TransactionException if no queries added to transaction
May 3, 2025
2c7e156
Add QueryResultException if name not exists
May 3, 2025
2dd2039
Add TransactionTest
May 3, 2025
371df36
Add QueryResultTest
May 3, 2025
26c0629
Add QueryResultCollectionTest
May 3, 2025
56194af
Refactor QueryResultCollection
May 3, 2025
7881328
Add handling Rollback errors and promise
May 4, 2025
332a8ed
Add test for resolveQueries method in Transaction Class
May 4, 2025
a15ce81
Add toArray method to QueryResult class
May 4, 2025
b403d3c
Add all and last method to QueryResultCollection class
May 4, 2025
59749eb
Add pass if callback is null and on commit resolve an array of last q…
May 4, 2025
7992b7d
Add transaction example
May 4, 2025
4e66edd
Add transaction doc (md file)
May 4, 2025
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
68 changes: 68 additions & 0 deletions docs/transaction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Transaction

**Transaction** class has three properties:

1. `queries`
2. `queryResultCollection`
3. `connection`

The <b>Transaction</b> class contains several methods such as :

- `addQuery`
- `resolveQueries`
- `compile`

### __constructor

when we create an instance of this class it reserves a database connection using `reserveConnection` method and stores it
in `connection` property, to use it
for all transaction queries.

### addQuery

This method takes three arguments :

1. `name`<small>(string)</small>.
2. `query`<small>(Select|Update|Delete|Insert)</small>.
3. `callback`<small>(Closure)</small> by default its null.

This method push arguments as an array to **queries** property

**Returns: `Transaction`**

### resolveQueries

this method doesn't take any argument.

This method flow:

- if `queries` is empty, it commits the transaction and return a promise with result of last query.
- if `queries` is not empty, it shifts the first item of this property and stores it in a variable.
- we get the query string of the selected query and send a query to database with this query and by using the reserved
connection.
- if the result of query returned `false` we `rollback` the transaction and return a reject promise with
`TransactionException` exception.
- otherwise if selected query has callback, we call and check the callback to be true (we inject two argument to this
callback, first the current query result and next the collection of all transaction query results).
- it the callback is null, or it returns true we call the current method , and it starts from first flow.

**Returns: `Promise`**

### compile

This method doesn't take any argument.

This method flow:

- if `queries` is empty, it throws `TransactionException('There are no queries inside transaction.')`.
- it starts the transaction.
- if transaction started, it calls the `resolveQueries` method, otherwise it release the reserved connection.

**Returns: `Promise`**







105 changes: 105 additions & 0 deletions example/transaction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

use Dotenv\Dotenv;
use React\EventLoop\Loop;
use Saraf\QB\QueryBuilder\Contracts\QueryResultCollectionContract;
use Saraf\QB\QueryBuilder\Core\DBFactory;
use Saraf\QB\QueryBuilder\Exceptions\DBFactoryException;
use Saraf\QB\QueryBuilder\Helpers\QueryResult\QueryResult;

include "vendor/autoload.php";

// Loop
$loop = Loop::get();

// Environments
$env = Dotenv::createImmutable(__DIR__ . "/../");
$env->load();

// Env Loader
$DB_NAME = $_ENV['DB_NAME'];
$DB_USER = $_ENV['DB_USER'];
$DB_PASS = $_ENV['DB_PASS'];
$DB_HOST = $_ENV['DB_HOST'];
$DB_PORT_READ = $_ENV['DB_PORT_READ'];
$DB_PORT_WRITE = $_ENV['DB_PORT_WRITE'];


try {
$dbFactory = new DBFactory(
$loop,
$DB_HOST,
$DB_NAME,
$DB_USER,
$DB_PASS,
$DB_PORT_WRITE,
$DB_PORT_READ,
5,
5,
2,
2
);
} catch (DBFactoryException $e) {
echo $e->getMessage();
exit(1);
}

$tr = $dbFactory->getQueryBuilder()->beginTransaction();
$tr->addQuery(
'selectUser',
$dbFactory->getQueryBuilder()
->select()
->from("Users")
->where('id', 1),
function (QueryResult $result) {
if ($result->count == 0) {
return false;
}
return true;
}
);
$tr->addQuery('selectBalance',
$dbFactory->getQueryBuilder()
->select()
->from('Balances')
->where('userId', 1)
->where('symbol', 'IRT')
);

$tr->addQuery('insertTransactions',
$dbFactory->getQueryBuilder()
->insert()
->into('Transactions')
->setColumns([
'userId',
'type',
'amount'
])
->addRow([
1,
'WITHDRAW',
100_000
]),
function (QueryResult $result, QueryResultCollectionContract $contract) {
if ($contract->get('selectBalance')->rows[0]['balance'] < 100_000) {
return false;
}

return true;
}
);

$tr->addQuery('updateBalance',
$dbFactory->getQueryBuilder()
->update()
->table('Balances')
->setUpdate('balance', 'balance - ' . 100_000, false)
->where('userId', 1)
->where('symbol', 'IRT')
);

$tr->compile()->then(function ($result) {
var_dump($result);
})->catch(function (\Throwable $throwable) {
var_dump($throwable->getMessage());
});
91 changes: 91 additions & 0 deletions src/QueryBuilder/Clauses/Transaction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

namespace Saraf\QB\QueryBuilder\Clauses;

use Saraf\QB\QueryBuilder\Core\DBFactory;
use Saraf\QB\QueryBuilder\Core\DBWorker;
use Saraf\QB\QueryBuilder\Exceptions\TransactionException;
use Saraf\QB\QueryBuilder\Helpers\QueryResult\QueryResult;
use Saraf\QB\QueryBuilder\Helpers\QueryResult\QueryResultCollection;
use function React\Promise\reject;
use function React\Promise\resolve;

class Transaction
{
protected array $queries = [];
protected QueryResultCollection $queryResultCollection;
private ?DBWorker $connection = null;

public function __construct(
protected ?DBFactory $dbFactory = null,
)
{
$this->queryResultCollection = new QueryResultCollection();
$this->connection = !is_null($this->dbFactory) ? $this->dbFactory->reserveConnection() : null;
}

public function addQuery(string $name, Select|Update|Delete|Insert $query, ?\Closure $callback = null): static
{
$this->queries[] = compact('name', 'query', 'callback');
return $this;
}

/**
* @throws TransactionException
*/
public function compile(): \React\Promise\PromiseInterface
{
if (count($this->queries) === 0) {
throw new TransactionException('There are no queries inside transaction.');
}

return $this->connection->query("START TRANSACTION")
->then(function () {
return $this->resolveQueries();
})
->finally(function () {
$this->dbFactory->releaseConnection($this->connection);
});
}

protected function resolveQueries(): \React\Promise\PromiseInterface
{
if (count($this->queries) === 0) {
$this->connection->query('COMMIT');
return resolve($this->queryResultCollection->last()->toArray());
}

$queryItem = array_shift($this->queries);

$query = $queryItem['query'];
$callback = $queryItem['callback'];
$name = $queryItem['name'];

return $query->compile()->getQuery()->then(function ($result) use ($query, $callback, $name) {
$queryString = $result['query'];
return $this->connection->query($queryString)
->then(function ($result) use ($query, $callback, $name, $queryString) {
if (!$result['result']) {
$this->connection->query('ROLLBACK');
return reject(throw new TransactionException('Transaction rolled back due to ' . $result['error']));
}

$queryResult = new QueryResult(
$result['result'],
@$result['count'] ?? null,
@$result['rows'] ?? [],
@$result['affectedRows'] ?? null,
@$result['insertId'] ?? null,
);

if (is_null($callback) || $callback($queryResult, $this->queryResultCollection)) {
$this->queryResultCollection->add($name, $queryResult);
return $this->resolveQueries();
}

$this->connection->query('ROLLBACK');
return reject(throw new TransactionException("Transaction rolled back,callback for query {$queryString} doesn't return true"));
});
});
}
}
11 changes: 11 additions & 0 deletions src/QueryBuilder/Contracts/QueryResultCollectionContract.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace Saraf\QB\QueryBuilder\Contracts;

use Saraf\QB\QueryBuilder\Helpers\QueryResult\QueryResult;

interface QueryResultCollectionContract
{
public function add(string $name, QueryResult $queryResult): self;
public function get(string $name): QueryResult;
}
2 changes: 2 additions & 0 deletions src/QueryBuilder/Core/DBFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

class DBFactory
{
use ReservableConnection;

private array $logs = [];
private const MAX_CONNECTION_COUNT = 1000000000;

Expand Down
16 changes: 16 additions & 0 deletions src/QueryBuilder/Core/ReservableConnection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Saraf\QB\QueryBuilder\Core;

trait ReservableConnection
{
public function reserveConnection(): DBWorker
{
return array_pop($this->writeConnections);
}

public function releaseConnection(DBWorker $connection): void
{
$this->writeConnections[] = $connection;
}
}
8 changes: 8 additions & 0 deletions src/QueryBuilder/Exceptions/QueryResultException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace Saraf\QB\QueryBuilder\Exceptions;

class QueryResultException extends \Exception
{

}
8 changes: 8 additions & 0 deletions src/QueryBuilder/Exceptions/TransactionException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace Saraf\QB\QueryBuilder\Exceptions;

class TransactionException extends \Exception
{

}
27 changes: 27 additions & 0 deletions src/QueryBuilder/Helpers/QueryResult/QueryResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace Saraf\QB\QueryBuilder\Helpers\QueryResult;

class QueryResult
{
public function __construct(
public bool $result,
public ?int $count = null,
public array $rows = [],
public ?int $affectedRows = null,
public ?int $insertId = null
)
{
}

public function toArray(): array
{
return [
'result' => $this->result,
'count' => $this->count,
'rows' => $this->rows,
'affectedRows' => $this->affectedRows,
'insertId' => $this->insertId
];
}
}
Loading