Skip to content

Commit

Permalink
Performance optimizations
Browse files Browse the repository at this point in the history
Composite PHP committed Dec 23, 2023

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
1 parent 1ae4801 commit ba50060
Showing 14 changed files with 204 additions and 63 deletions.
6 changes: 3 additions & 3 deletions src/AbstractCachedTable.php
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@

abstract class AbstractCachedTable extends AbstractTable
{
use SelectRawTrait;
use Helpers\SelectRawTrait;

protected const CACHE_VERSION = 1;

@@ -26,7 +26,7 @@ abstract protected function getFlushCacheKeys(AbstractEntity $entity): array;
/**
* @throws \Throwable
*/
public function save(AbstractEntity &$entity): void
public function save(AbstractEntity $entity): void
{
$cacheKeys = $this->collectCacheKeysByEntity($entity);
parent::save($entity);
@@ -54,7 +54,7 @@ public function saveMany(array $entities): void
/**
* @throws \Throwable
*/
public function delete(AbstractEntity &$entity): void
public function delete(AbstractEntity $entity): void
{
$cacheKeys = $this->collectCacheKeysByEntity($entity);
parent::delete($entity);
96 changes: 51 additions & 45 deletions src/AbstractTable.php
Original file line number Diff line number Diff line change
@@ -2,21 +2,22 @@

namespace Composite\DB;

use Composite\DB\Exceptions\DbException;
use Composite\DB\MultiQuery\MultiInsert;
use Composite\DB\MultiQuery\MultiSelect;
use Composite\Entity\Helpers\DateTimeHelper;
use Composite\Entity\AbstractEntity;
use Composite\DB\Exceptions\DbException;
use Composite\Entity\Helpers\DateTimeHelper;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Ramsey\Uuid\UuidInterface;

abstract class AbstractTable
{
use SelectRawTrait;
use Helpers\SelectRawTrait;
use Helpers\DatabaseSpecificTrait;

protected readonly TableConfig $config;


abstract protected function getConfig(): TableConfig;

public function __construct()
@@ -44,49 +45,51 @@ public function getConnectionName(): string
* @return void
* @throws \Throwable
*/
public function save(AbstractEntity &$entity): void
public function save(AbstractEntity $entity): void
{
$this->config->checkEntity($entity);
if ($entity->isNew()) {
$connection = $this->getConnection();
$this->checkUpdatedAt($entity);

$insertData = $this->formatData($entity->toArray());
$insertData = $this->prepareDataForSql($entity->toArray());
$this->getConnection()->insert($this->getTableName(), $insertData);

if ($this->config->autoIncrementKey) {
$insertData[$this->config->autoIncrementKey] = intval($connection->lastInsertId());
$entity = $entity::fromArray($insertData);
} else {
$entity->resetChangedColumns();
if ($this->config->autoIncrementKey && ($lastInsertedId = $connection->lastInsertId())) {
$insertData[$this->config->autoIncrementKey] = intval($lastInsertedId);
$entity::schema()
->getColumn($this->config->autoIncrementKey)
->setValue($entity, $insertData[$this->config->autoIncrementKey]);
}
$entity->resetChangedColumns($insertData);
} else {
if (!$changedColumns = $entity->getChangedColumns()) {
return;
}
$connection = $this->getConnection();
$where = $this->getPkCondition($entity);

$changedColumns = $this->prepareDataForSql($changedColumns);
if ($this->config->hasUpdatedAt() && property_exists($entity, 'updated_at')) {
$entity->updated_at = new \DateTimeImmutable();
$changedColumns['updated_at'] = DateTimeHelper::dateTimeToString($entity->updated_at);
}
$whereParams = $this->getPkCondition($entity);
if ($this->config->hasOptimisticLock()
&& method_exists($entity, 'getVersion')
&& method_exists($entity, 'incrementVersion')) {
$where['lock_version'] = $entity->getVersion();
$whereParams['lock_version'] = $entity->getVersion();
$entity->incrementVersion();
$changedColumns['lock_version'] = $entity->getVersion();
}
$entityUpdated = $connection->update(
table: $this->getTableName(),
data: $changedColumns,
criteria: $where,
$updateString = implode(', ', array_map(fn ($key) => $this->escapeIdentifier($key) . "=?", array_keys($changedColumns)));
$whereString = implode(' AND ', array_map(fn ($key) => $this->escapeIdentifier($key) . "=?", array_keys($whereParams)));

$entityUpdated = (bool)$this->getConnection()->executeStatement(
sql: "UPDATE " . $this->escapeIdentifier($this->getTableName()) . " SET $updateString WHERE $whereString;",
params: array_merge(array_values($changedColumns), array_values($whereParams)),
);
if ($this->config->hasOptimisticLock() && !$entityUpdated) {
throw new Exceptions\LockException('Failed to update entity version, concurrency modification, rolling back.');
}
$entity->resetChangedColumns();
$entity->resetChangedColumns($changedColumns);
}
}

@@ -101,7 +104,7 @@ public function saveMany(array $entities): void
if ($entity->isNew()) {
$this->config->checkEntity($entity);
$this->checkUpdatedAt($entity);
$rowsToInsert[] = $this->formatData($entity->toArray());
$rowsToInsert[] = $this->prepareDataForSql($entity->toArray());
unset($entities[$i]);
}
}
@@ -113,14 +116,15 @@ public function saveMany(array $entities): void
}
if ($rowsToInsert) {
$chunks = array_chunk($rowsToInsert, 1000);
$connection = $this->getConnection();
foreach ($chunks as $chunk) {
$multiInsert = new MultiInsert(
connection: $connection,
tableName: $this->getTableName(),
rows: $chunk,
);
if ($multiInsert->getSql()) {
$stmt = $this->getConnection()->prepare($multiInsert->getSql());
$stmt->executeQuery($multiInsert->getParameters());
$connection->executeStatement($multiInsert->getSql(), $multiInsert->getParameters());
}
}
}
@@ -135,7 +139,7 @@ public function saveMany(array $entities): void
* @param AbstractEntity $entity
* @throws \Throwable
*/
public function delete(AbstractEntity &$entity): void
public function delete(AbstractEntity $entity): void
{
$this->config->checkEntity($entity);
if ($this->config->hasSoftDelete()) {
@@ -144,8 +148,12 @@ public function delete(AbstractEntity &$entity): void
$this->save($entity);
}
} else {
$where = $this->getPkCondition($entity);
$this->getConnection()->delete($this->getTableName(), $where);
$whereParams = $this->getPkCondition($entity);
$whereString = implode(' AND ', array_map(fn ($key) => $this->escapeIdentifier($key) . "=?", array_keys($whereParams)));
$this->getConnection()->executeQuery(
sql: "DELETE FROM " . $this->escapeIdentifier($this->getTableName()) . " WHERE $whereString;",
params: array_values($whereParams),
);
}
}

@@ -192,8 +200,15 @@ protected function _countAll(array|Where $where = []): int
*/
protected function _findByPk(mixed $pk): mixed
{
$where = $this->getPkCondition($pk);
return $this->_findOne($where);
$whereParams = $this->getPkCondition($pk);
$whereString = implode(' AND ', array_map(fn ($key) => $this->escapeIdentifier($key) . "=?", array_keys($whereParams)));
$row = $this->getConnection()
->executeQuery(
sql: "SELECT * FROM " . $this->escapeIdentifier($this->getTableName()) . " WHERE $whereString;",
params: array_values($whereParams),
)
->fetchAssociative();
return $this->createEntity($row);
}

/**
@@ -304,7 +319,14 @@ protected function getPkCondition(int|string|array|AbstractEntity|UuidInterface
{
$condition = [];
if ($data instanceof AbstractEntity) {
$data = $data->toArray();
if ($data->isNew()) {
$data = $data->toArray();
} else {
foreach ($this->config->primaryKeys as $key) {
$condition[$key] = $data->getOldValue($key);
}
return $condition;
}
}
if (is_array($data)) {
foreach ($this->config->primaryKeys as $key) {
@@ -324,20 +346,4 @@ private function checkUpdatedAt(AbstractEntity $entity): void
$entity->updated_at = new \DateTimeImmutable();
}
}

/**
* @param array<string, mixed> $data
* @return array<string, mixed>
* @throws \Doctrine\DBAL\Exception
*/
private function formatData(array $data): array
{
$supportsBoolean = $this->getConnection()->getDatabasePlatform() instanceof PostgreSQLPlatform;
foreach ($data as $columnName => $value) {
if (is_bool($value) && !$supportsBoolean) {
$data[$columnName] = $value ? 1 : 0;
}
}
return $data;
}
}
60 changes: 60 additions & 0 deletions src/Helpers/DatabaseSpecificTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php declare(strict_types=1);

namespace Composite\DB\Helpers;

use Composite\DB\Exceptions\DbException;
use Doctrine\DBAL\Driver;

trait DatabaseSpecificTrait
{
private ?bool $isPostgreSQL = null;
private ?bool $isMySQL = null;
private ?bool $isSQLite = null;

private function identifyPlatform(): void
{
if ($this->isPostgreSQL !== null) {
return;
}
$driver = $this->getConnection()->getDriver();
if ($driver instanceof Driver\AbstractPostgreSQLDriver) {
$this->isPostgreSQL = true;
$this->isMySQL = $this->isSQLite = false;
} elseif ($driver instanceof Driver\AbstractSQLiteDriver) {
$this->isSQLite = true;
$this->isPostgreSQL = $this->isMySQL = false;
} elseif ($driver instanceof Driver\AbstractMySQLDriver) {
$this->isMySQL = true;
$this->isPostgreSQL = $this->isSQLite = false;
} else {
// @codeCoverageIgnoreStart
throw new DbException('Unsupported driver ' . $driver::class);
// @codeCoverageIgnoreEnd
}
}

/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function prepareDataForSql(array $data): array
{
$this->identifyPlatform();
foreach ($data as $columnName => $value) {
if (is_bool($value) && !$this->isPostgreSQL) {
$data[$columnName] = $value ? 1 : 0;
}
}
return $data;
}

protected function escapeIdentifier(string $key): string
{
$this->identifyPlatform();
if ($this->isMySQL) {
return implode('.', array_map(fn ($part) => "`$part`", explode('.', $key)));
} else {
return '"' . $key . '"';
}
}
}
3 changes: 2 additions & 1 deletion src/SelectRawTrait.php → src/Helpers/SelectRawTrait.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<?php declare(strict_types=1);

namespace Composite\DB;
namespace Composite\DB\Helpers;

use Composite\DB\Where;
use Doctrine\DBAL\Query\QueryBuilder;

trait SelectRawTrait
18 changes: 15 additions & 3 deletions src/MultiQuery/MultiInsert.php
Original file line number Diff line number Diff line change
@@ -2,8 +2,14 @@

namespace Composite\DB\MultiQuery;

use Composite\DB\Helpers\DatabaseSpecificTrait;
use Doctrine\DBAL\Connection;

class MultiInsert
{
use DatabaseSpecificTrait;

private Connection $connection;
private string $sql = '';
/** @var array<string, mixed> */
private array $parameters = [];
@@ -12,13 +18,14 @@ class MultiInsert
* @param string $tableName
* @param list<array<string, mixed>> $rows
*/
public function __construct(string $tableName, array $rows) {
public function __construct(Connection $connection, string $tableName, array $rows) {
if (!$rows) {
return;
}
$this->connection = $connection;
$firstRow = reset($rows);
$columnNames = array_map(fn($columnName) => "`$columnName`", array_keys($firstRow));
$this->sql = "INSERT INTO `$tableName` (" . implode(', ', $columnNames) . ") VALUES ";
$columnNames = array_map(fn ($columnName) => $this->escapeIdentifier($columnName), array_keys($firstRow));
$this->sql = "INSERT INTO " . $this->escapeIdentifier($tableName) . " (" . implode(', ', $columnNames) . ") VALUES ";
$valuesSql = [];

$index = 0;
@@ -47,4 +54,9 @@ public function getParameters(): array
{
return $this->parameters;
}

private function getConnection(): Connection
{
return $this->connection;
}
}
10 changes: 6 additions & 4 deletions tests/MultiQuery/MultiInsertTest.php
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@

namespace Composite\DB\Tests\MultiQuery;

use Composite\DB\ConnectionManager;
use Composite\DB\MultiQuery\MultiInsert;

class MultiInsertTest extends \PHPUnit\Framework\TestCase
@@ -11,7 +12,8 @@ class MultiInsertTest extends \PHPUnit\Framework\TestCase
*/
public function test_multiInsertQuery($tableName, $rows, $expectedSql, $expectedParameters)
{
$multiInserter = new MultiInsert($tableName, $rows);
$connection = ConnectionManager::getConnection('sqlite');
$multiInserter = new MultiInsert($connection, $tableName, $rows);

$this->assertEquals($expectedSql, $multiInserter->getSql());
$this->assertEquals($expectedParameters, $multiInserter->getParameters());
@@ -31,7 +33,7 @@ public static function multiInsertQuery_dataProvider()
[
['a' => 'value1_1', 'b' => 'value2_1'],
],
"INSERT INTO `testTable` (`a`, `b`) VALUES (:a0, :b0);",
'INSERT INTO "testTable" ("a", "b") VALUES (:a0, :b0);',
['a0' => 'value1_1', 'b0' => 'value2_1']
],
[
@@ -40,7 +42,7 @@ public static function multiInsertQuery_dataProvider()
['a' => 'value1_1', 'b' => 'value2_1'],
['a' => 'value1_2', 'b' => 'value2_2']
],
"INSERT INTO `testTable` (`a`, `b`) VALUES (:a0, :b0), (:a1, :b1);",
'INSERT INTO "testTable" ("a", "b") VALUES (:a0, :b0), (:a1, :b1);',
['a0' => 'value1_1', 'b0' => 'value2_1', 'a1' => 'value1_2', 'b1' => 'value2_2']
],
[
@@ -49,7 +51,7 @@ public static function multiInsertQuery_dataProvider()
['column1' => 'value1_1'],
['column1' => 123]
],
"INSERT INTO `testTable` (`column1`) VALUES (:column10), (:column11);",
'INSERT INTO "testTable" ("column1") VALUES (:column10), (:column11);',
['column10' => 'value1_1', 'column11' => 123]
]
];
12 changes: 11 additions & 1 deletion tests/Table/AbstractTableTest.php
Original file line number Diff line number Diff line change
@@ -98,7 +98,7 @@ public function test_illegalCreateEntity(): void
/**
* @dataProvider buildWhere_dataProvider
*/
public function test_buildWhere($where, $expectedSQL, $expectedParams)
public function test_buildWhere($where, $expectedSQL, $expectedParams): void
{
$table = new Tables\TestStrictTable();

@@ -190,4 +190,14 @@ public static function buildWhere_dataProvider(): array
]
];
}

public function test_databaseSpecific(): void
{
$mySQLTable = new Tables\TestMySQLTable();
$this->assertEquals('`column`', $mySQLTable->escapeIdentifierPub('column'));
$this->assertEquals('`Database`.`Table`', $mySQLTable->escapeIdentifierPub('Database.Table'));

$postgresTable = new Tables\TestPostgresTable();
$this->assertEquals('"column"', $postgresTable->escapeIdentifierPub('column'));
}
}
2 changes: 1 addition & 1 deletion tests/TestStand/Tables/TestAutoincrementCachedTable.php
Original file line number Diff line number Diff line change
@@ -48,7 +48,7 @@ public function findOneByName(string $name): ?TestAutoincrementEntity
return $this->_findOneCached(['name' => $name]);
}

public function delete(TestAutoincrementEntity|AbstractEntity &$entity): void
public function delete(TestAutoincrementEntity|AbstractEntity $entity): void
{
if ($entity->name === 'Exception') {
throw new \Exception('Test Exception');
2 changes: 1 addition & 1 deletion tests/TestStand/Tables/TestAutoincrementSdCachedTable.php
Original file line number Diff line number Diff line change
@@ -48,7 +48,7 @@ public function findOneByName(string $name): ?TestAutoincrementSdEntity
return $this->_findOneCached(['name' => $name]);
}

public function delete(TestAutoincrementSdEntity|AbstractEntity &$entity): void
public function delete(TestAutoincrementSdEntity|AbstractEntity $entity): void
{
if ($entity->name === 'Exception') {
throw new \Exception('Test Exception');
2 changes: 1 addition & 1 deletion tests/TestStand/Tables/TestAutoincrementTable.php
Original file line number Diff line number Diff line change
@@ -32,7 +32,7 @@ public function findOneByName(string $name): ?TestAutoincrementEntity
return $this->_findOne(['name' => $name]);
}

public function delete(AbstractEntity|TestAutoincrementEntity &$entity): void
public function delete(AbstractEntity|TestAutoincrementEntity $entity): void
{
if ($entity->name === 'Exception') {
throw new \Exception('Test Exception');
4 changes: 2 additions & 2 deletions tests/TestStand/Tables/TestCompositeTable.php
Original file line number Diff line number Diff line change
@@ -15,15 +15,15 @@ protected function getConfig(): TableConfig
return TableConfig::fromEntitySchema(TestCompositeEntity::schema());
}

public function save(AbstractEntity|TestCompositeEntity &$entity): void
public function save(AbstractEntity|TestCompositeEntity $entity): void
{
if ($entity->message === 'Exception') {
throw new \Exception('Test Exception');
}
parent::save($entity);
}

public function delete(AbstractEntity|TestCompositeEntity &$entity): void
public function delete(AbstractEntity|TestCompositeEntity $entity): void
{
if ($entity->message === 'Exception') {
throw new \Exception('Test Exception');
25 changes: 25 additions & 0 deletions tests/TestStand/Tables/TestMySQLTable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php declare(strict_types=1);

namespace Composite\DB\Tests\TestStand\Tables;

use Composite\DB\AbstractTable;
use Composite\DB\TableConfig;
use Composite\DB\Tests\TestStand\Entities\TestAutoincrementEntity;

class TestMySQLTable extends AbstractTable
{
protected function getConfig(): TableConfig
{
return new TableConfig(
connectionName: 'mysql',
tableName: 'Fake',
entityClass: TestAutoincrementEntity::class,
primaryKeys: [],
);
}

public function escapeIdentifierPub(string $key): string
{
return $this->escapeIdentifier($key);
}
}
25 changes: 25 additions & 0 deletions tests/TestStand/Tables/TestPostgresTable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php declare(strict_types=1);

namespace Composite\DB\Tests\TestStand\Tables;

use Composite\DB\AbstractTable;
use Composite\DB\TableConfig;
use Composite\DB\Tests\TestStand\Entities\TestAutoincrementEntity;

class TestPostgresTable extends AbstractTable
{
protected function getConfig(): TableConfig
{
return new TableConfig(
connectionName: 'postgres',
tableName: 'Fake',
entityClass: TestAutoincrementEntity::class,
primaryKeys: [],
);
}

public function escapeIdentifierPub(string $key): string
{
return $this->escapeIdentifier($key);
}
}
2 changes: 1 addition & 1 deletion tests/TestStand/Tables/TestUniqueTable.php
Original file line number Diff line number Diff line change
@@ -19,7 +19,7 @@ public function __construct()
$this->init();
}

public function save(AbstractEntity|TestUniqueEntity &$entity): void
public function save(AbstractEntity|TestUniqueEntity $entity): void
{
if ($entity->name === 'Exception') {
throw new \Exception('Test Exception');

0 comments on commit ba50060

Please sign in to comment.