From 100017b8c62ac150972a1745e2c5095abc8acb23 Mon Sep 17 00:00:00 2001 From: HilarioJrx Date: Thu, 14 Aug 2025 19:40:45 -0300 Subject: [PATCH 01/17] feat: extend build methods to accept DbDriverInterface and add MySQL alias and subquery join tests --- src/DeleteQuery.php | 3 +- src/InsertBulkQuery.php | 19 +++++--- src/InsertQuery.php | 17 ++++--- src/InsertSelectQuery.php | 17 ++++--- src/Interface/UpdateBuilderInterface.php | 2 +- src/UpdateQuery.php | 38 +++++++++++++--- tests/UpdateQueryTest.php | 57 ++++++++++++++++++++++++ 7 files changed, 126 insertions(+), 27 deletions(-) diff --git a/src/DeleteQuery.php b/src/DeleteQuery.php index 9a4baa7..e1c548c 100644 --- a/src/DeleteQuery.php +++ b/src/DeleteQuery.php @@ -2,6 +2,7 @@ namespace ByJG\MicroOrm; +use ByJG\AnyDataset\Db\DbDriverInterface; use ByJG\AnyDataset\Db\DbFunctionsInterface; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\Interface\QueryBuilderInterface; @@ -13,7 +14,7 @@ public static function getInstance(): DeleteQuery return new DeleteQuery(); } - public function build(DbFunctionsInterface $dbHelper = null): SqlObject + public function build(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlObject { $whereStr = $this->getWhere(); if (is_null($whereStr)) { diff --git a/src/InsertBulkQuery.php b/src/InsertBulkQuery.php index 4f931bf..a41247e 100644 --- a/src/InsertBulkQuery.php +++ b/src/InsertBulkQuery.php @@ -2,6 +2,7 @@ namespace ByJG\MicroOrm; +use ByJG\AnyDataset\Db\DbDriverInterface; use ByJG\AnyDataset\Db\DbFunctionsInterface; use ByJG\MicroOrm\Exception\OrmInvalidFieldsException; use ByJG\MicroOrm\Interface\QueryBuilderInterface; @@ -60,19 +61,23 @@ public function values(array $values, bool $allowNonMatchFields = true): static } /** - * @param DbFunctionsInterface|null $dbHelper + * @param DbDriverInterface|DbFunctionsInterface|null $dbDriverOrHelper * @return SqlObject * @throws OrmInvalidFieldsException */ - public function build(DbFunctionsInterface $dbHelper = null): SqlObject + public function build(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlObject { if (empty($this->fields)) { throw new OrmInvalidFieldsException('You must specify the fields for insert'); } + if ($dbDriverOrHelper instanceof DbDriverInterface) { + $dbDriverOrHelper = $dbDriverOrHelper->getDbHelper(); + } + $tableStr = $this->table; - if (!is_null($dbHelper)) { - $tableStr = $dbHelper->delimiterTable($tableStr); + if (!is_null($dbDriverOrHelper)) { + $tableStr = $dbDriverOrHelper->delimiterTable($tableStr); } // Extract column names @@ -96,7 +101,7 @@ public function build(DbFunctionsInterface $dbHelper = null): SqlObject } else { $value = str_replace("'", "''", $this->fields[$col][$i]); if (!is_numeric($value)) { - $value = $dbHelper?->delimiterField($value) ?? "'{$value}'"; + $value = $dbDriverOrHelper?->delimiterField($value) ?? "'{$value}'"; } $params[$paramKey] = new Literal($value); // Map parameter key to value } @@ -104,8 +109,8 @@ public function build(DbFunctionsInterface $dbHelper = null): SqlObject $placeholders[] = '(' . implode(', ', $rowPlaceholders) . ')'; // Add row placeholders to query } - if (!is_null($dbHelper)) { - $columns = $dbHelper->delimiterField($columns); + if (!is_null($dbDriverOrHelper)) { + $columns = $dbDriverOrHelper->delimiterField($columns); } // Construct the final SQL query diff --git a/src/InsertQuery.php b/src/InsertQuery.php index 908a771..08b305b 100644 --- a/src/InsertQuery.php +++ b/src/InsertQuery.php @@ -2,6 +2,7 @@ namespace ByJG\MicroOrm; +use ByJG\AnyDataset\Db\DbDriverInterface; use ByJG\AnyDataset\Db\DbFunctionsInterface; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\Exception\OrmInvalidFieldsException; @@ -65,24 +66,28 @@ public function defineFields(array $fields): static } /** - * @param DbFunctionsInterface|null $dbHelper + * @param DbDriverInterface|DbFunctionsInterface|null $dbDriverOrHelper * @return SqlObject * @throws OrmInvalidFieldsException */ - public function build(DbFunctionsInterface $dbHelper = null): SqlObject + public function build(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlObject { if (empty($this->values)) { throw new OrmInvalidFieldsException('You must specify the fields for insert'); } + if ($dbDriverOrHelper instanceof DbDriverInterface) { + $dbDriverOrHelper = $dbDriverOrHelper->getDbHelper(); + } + $fieldsStr = array_keys($this->values); // get the fields from the first element only - if (!is_null($dbHelper)) { - $fieldsStr = $dbHelper->delimiterField($fieldsStr); + if (!is_null($dbDriverOrHelper)) { + $fieldsStr = $dbDriverOrHelper->delimiterField($fieldsStr); } $tableStr = $this->table; - if (!is_null($dbHelper)) { - $tableStr = $dbHelper->delimiterTable($tableStr); + if (!is_null($dbDriverOrHelper)) { + $tableStr = $dbDriverOrHelper->delimiterTable($tableStr); } $sql = 'INSERT INTO ' diff --git a/src/InsertSelectQuery.php b/src/InsertSelectQuery.php index c5c0fba..b8f3270 100644 --- a/src/InsertSelectQuery.php +++ b/src/InsertSelectQuery.php @@ -2,6 +2,7 @@ namespace ByJG\MicroOrm; +use ByJG\AnyDataset\Db\DbDriverInterface; use ByJG\AnyDataset\Db\DbFunctionsInterface; use ByJG\MicroOrm\Exception\OrmInvalidFieldsException; use ByJG\MicroOrm\Interface\QueryBuilderInterface; @@ -49,16 +50,20 @@ public function fromSqlObject(SqlObject $sqlObject): static } /** - * @param DbFunctionsInterface|null $dbHelper + * @param DbDriverInterface|DbFunctionsInterface|null $dbDriverOrHelper * @return SqlObject * @throws OrmInvalidFieldsException */ - public function build(DbFunctionsInterface $dbHelper = null): SqlObject + public function build(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlObject { if (empty($this->fields)) { throw new OrmInvalidFieldsException('You must specify the fields for insert'); } + if ($dbDriverOrHelper instanceof DbDriverInterface) { + $dbDriverOrHelper = $dbDriverOrHelper->getDbHelper(); + } + if (empty($this->query) && empty($this->sqlObject)) { throw new OrmInvalidFieldsException('You must specify the query for insert'); } elseif (!empty($this->query) && !empty($this->sqlObject)) { @@ -66,13 +71,13 @@ public function build(DbFunctionsInterface $dbHelper = null): SqlObject } $fieldsStr = $this->fields; - if (!is_null($dbHelper)) { - $fieldsStr = $dbHelper->delimiterField($fieldsStr); + if (!is_null($dbDriverOrHelper)) { + $fieldsStr = $dbDriverOrHelper->delimiterField($fieldsStr); } $tableStr = $this->table; - if (!is_null($dbHelper)) { - $tableStr = $dbHelper->delimiterTable($tableStr); + if (!is_null($dbDriverOrHelper)) { + $tableStr = $dbDriverOrHelper->delimiterTable($tableStr); } $sql = 'INSERT INTO ' diff --git a/src/Interface/UpdateBuilderInterface.php b/src/Interface/UpdateBuilderInterface.php index 56c76a8..699abbd 100644 --- a/src/Interface/UpdateBuilderInterface.php +++ b/src/Interface/UpdateBuilderInterface.php @@ -8,7 +8,7 @@ interface UpdateBuilderInterface { - public function build(?DbFunctionsInterface $dbHelper = null): SqlObject; + public function build(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlObject; public function buildAndExecute(DbDriverInterface $dbDriver, $params = [], ?DbFunctionsInterface $dbHelper = null); diff --git a/src/UpdateQuery.php b/src/UpdateQuery.php index 24f0a0e..d68172f 100644 --- a/src/UpdateQuery.php +++ b/src/UpdateQuery.php @@ -2,7 +2,9 @@ namespace ByJG\MicroOrm; +use ByJG\AnyDataset\Db\DbDriverInterface; use ByJG\AnyDataset\Db\DbFunctionsInterface; +use ByJG\AnyDataset\Db\SqlStatement; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\Interface\QueryBuilderInterface; use ByJG\MicroOrm\Literal\Literal; @@ -65,34 +67,58 @@ public function setLiteral(string $field, mixed $value): UpdateQuery return $this; } - protected function getJoinTables(DbFunctionsInterface $dbHelper = null): array + protected function getJoinTables(DbFunctionsInterface|DbDriverInterface $dbDriverOrHelper = null): array { + $dbDriver = null; + if ($dbDriverOrHelper instanceof DbDriverInterface) { + $dbDriver = $dbDriverOrHelper; + $dbHelper = $dbDriverOrHelper->getDbHelper(); + } else { + $dbHelper = $dbDriverOrHelper; + } + if (is_null($dbHelper)) { if (!empty($this->joinTables)) { throw new InvalidArgumentException('You must specify a DbFunctionsInterface to use join tables'); } return ['sql' => '', 'position' => 'before_set']; } + foreach ($this->joinTables as $key => $joinTable) { + if ($this->joinTables[$key]['table'] instanceof QueryBasic) { + if (is_null($dbDriver)) { + Throw new InvalidArgumentException('You must specify a DbDriverInterface to use Query join tables'); + } + $this->joinTables[$key]['table'] = new SqlStatement($this->joinTables[$key]['table']->build($dbDriver)->getSql()); + } + } return $dbHelper->getJoinTablesUpdate($this->joinTables); } - public function join(string $table, string $joinCondition): UpdateQuery + public function join(QueryBasic|string $table, string $joinCondition, ?string $alias = null): UpdateQuery { - $this->joinTables[] = ["table" => $table, "condition" => $joinCondition]; + $this->joinTables[] = ["table" => $table, "condition" => $joinCondition, "alias" => $alias]; return $this; } /** - * @param DbFunctionsInterface|null $dbHelper + * @param DbDriverInterface|DbFunctionsInterface|null $dbDriverOrHelper * @return SqlObject * @throws InvalidArgumentException */ - public function build(DbFunctionsInterface $dbHelper = null): SqlObject + public function build(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlObject { if (empty($this->set)) { throw new InvalidArgumentException('You must specify the fields for update'); } + + $dbDriver = null; + if ($dbDriverOrHelper instanceof DbDriverInterface) { + $dbHelper = $dbDriverOrHelper->getDbHelper(); + $dbDriver = $dbDriverOrHelper; + } else { + $dbHelper = $dbDriverOrHelper; + } $fieldsStr = []; $params = []; @@ -120,7 +146,7 @@ public function build(DbFunctionsInterface $dbHelper = null): SqlObject $tableName = $dbHelper->delimiterTable($tableName); } - $joinTables = $this->getJoinTables($dbHelper); + $joinTables = $this->getJoinTables($dbDriverOrHelper); $joinBeforeSet = $joinTables['position'] === 'before_set' ? $joinTables['sql'] : ''; $joinAfterSet = $joinTables['position'] === 'after_set' ? $joinTables['sql'] : ''; diff --git a/tests/UpdateQueryTest.php b/tests/UpdateQueryTest.php index 5e1be53..09a76c1 100644 --- a/tests/UpdateQueryTest.php +++ b/tests/UpdateQueryTest.php @@ -2,13 +2,18 @@ namespace Tests; +use ByJG\AnyDataset\Db\Factory; use ByJG\AnyDataset\Db\Helpers\DbMysqlFunctions; use ByJG\AnyDataset\Db\Helpers\DbPgsqlFunctions; use ByJG\AnyDataset\Db\Helpers\DbSqliteFunctions; +use ByJG\AnyDataset\Db\PdoMysql; +use ByJG\AnyDataset\Db\PdoObj; use ByJG\MicroOrm\Exception\InvalidArgumentException; +use ByJG\MicroOrm\Query; use ByJG\MicroOrm\SqlObject; use ByJG\MicroOrm\SqlObjectEnum; use ByJG\MicroOrm\UpdateQuery; +use ByJG\Util\Uri; use PHPUnit\Framework\TestCase; class UpdateQueryTest extends TestCase @@ -144,6 +149,58 @@ public function testUpdateJoinMySQl() ); } + public function testUpdateJoinMySQlAlias() + { + $this->object->table('test'); + $this->object->join('table2', 't2.id = test.id', 't2'); + $this->object->set('fld1', 'A'); + $this->object->set('fld2', 'B'); + $this->object->set('fld3', 'C'); + $this->object->where('fld1 = :id', ['id' => 10]); + + $sqlObject = $this->object->build(new DbMysqlFunctions()); + $this->assertEquals( + new SqlObject( + 'UPDATE `test` INNER JOIN `table2` AS t2 ON t2.id = test.id SET `fld1` = :fld1 , `fld2` = :fld2 , `fld3` = :fld3 WHERE fld1 = :id', + ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'], + SqlObjectEnum::UPDATE + ), + $sqlObject + ); + } + + public function testUpdateJoinMySQlSubqueryAlias() + { + $query = Query::getInstance() + ->table('table2') + ->where('id = 10'); + $this->object->table('test'); + $this->object->join($query, 't2.id = test.id', 't2'); + $this->object->set('fld1', 'A'); + $this->object->set('fld2', 'B'); + $this->object->set('fld3', 'C'); + $this->object->where('fld1 = :id', ['id' => 10]); + + + $dbDriver = new class extends PdoMysql + { + public function __construct() + { + $this->pdoObj = new PdoObj(new Uri('mysql://root:root@localhost/test')); + } + }; + + $sqlObject = $this->object->build($dbDriver); + $this->assertEquals( + new SqlObject( + 'UPDATE `test` INNER JOIN (SELECT * FROM table2 WHERE id = 10) AS t2 ON t2.id = test.id SET `fld1` = :fld1 , `fld2` = :fld2 , `fld3` = :fld3 WHERE fld1 = :id', + ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'], + SqlObjectEnum::UPDATE + ), + $sqlObject + ); + } + public function testUpdateJoinPostgres() { $this->object->table('test'); From 6a8b4ca6a411e88592879058922a117d1e4547e9 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Wed, 3 Sep 2025 12:38:30 -0400 Subject: [PATCH 02/17] Add `bulkExecute` method to Repository with transaction support Implemented `bulkExecute` method to allow atomic execution of multiple write queries (insert, update, delete) within a transaction. Added support for optional isolation levels, detailed exception handling, and comprehensive validation. Updated documentation and included unit tests to ensure correctness and reliability. --- docs/updating-the-database.md | 63 ++++++++++++++- src/Repository.php | 41 +++++++++- tests/BulkTest.php | 144 ++++++++++++++++++++++++++++++++++ 3 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 tests/BulkTest.php diff --git a/docs/updating-the-database.md b/docs/updating-the-database.md index 130e12a..b143c30 100644 --- a/docs/updating-the-database.md +++ b/docs/updating-the-database.md @@ -136,4 +136,65 @@ You can delete records using the `DeleteQuery` object. See an example: $deleteQuery = new \ByJG\MicroOrm\DeleteQuery(); $deleteQuery->table('test'); $deleteQuery->where('fld1 = :value', ['value' => 'A']); -``` \ No newline at end of file +``` + +## Execute multiple write queries in bulk + +You can execute multiple write queries (insert, update, delete) sequentially within a single transaction using +`Repository::bulkExecute`. +This is useful when you need to perform a set of changes atomically: either all of them succeed, or none of them are +applied. + +Signature: + +```php +public function Repository::bulkExecute(array $queries, ?\ByJG\AnyDataset\Db\IsolationLevelEnum $isolationLevel = null): void +``` + +Rules and behavior: + +- Accepts an array of QueryBuilderInterface or Updatable instances (e.g., InsertQuery, UpdateQuery, DeleteQuery). Each + item is built and executed with the repository write driver. +- All queries are executed inside a transaction. If any query throws an exception, the transaction is rolled back and + the exception is rethrown. +- Passing an empty array throws InvalidArgumentException. +- Passing an item that is not a QueryBuilderInterface or Updatable throws InvalidArgumentException. +- You can optionally pass a transaction isolation level using IsolationLevelEnum. The transaction allows joining an + existing transaction if present. + +Example: + +```php + 'Alice', + 'createdate' => '2020-01-01' +]); + +$update = UpdateQuery::getInstance() + ->table('users') + ->set('name', 'Bob') + ->where('id = :id', ['id' => 1]); + +$delete = DeleteQuery::getInstance() + ->table('users') + ->where('name = :name', ['name' => 'OldName']); + +$repository->bulkExecute([$insert, $update, $delete]); +``` + +Notes: + +- Parameter names can overlap between queries (e.g., multiple queries using :name) because each query is built and + executed independently. +- If you need a specific transaction isolation level, pass it as the second argument, e.g., + `IsolationLevelEnum::SERIALIZABLE`. \ No newline at end of file diff --git a/src/Repository.php b/src/Repository.php index 909530b..8c26e12 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -5,6 +5,7 @@ use ByJG\AnyDataset\Core\Enum\Relation; use ByJG\AnyDataset\Core\IteratorFilter; use ByJG\AnyDataset\Db\DbDriverInterface; +use ByJG\AnyDataset\Db\IsolationLevelEnum; use ByJG\AnyDataset\Db\IteratorFilterSqlFormatter; use ByJG\AnyDataset\Db\SqlStatement; use ByJG\MicroOrm\Exception\InvalidArgumentException; @@ -22,6 +23,7 @@ use Closure; use ReflectionException; use stdClass; +use Throwable; class Repository { @@ -188,6 +190,43 @@ public function delete(array|string|int|LiteralInterface $pkId): bool return $this->deleteByQuery($updatable); } + /** + * Execute multiple write queries (insert/update/delete) sequentially within a transaction. + * Invalid entries are ignored silently. If any execution fails, the transaction is rolled back. + * + * @param array $queries List of queries to be executed in bulk + * @param IsolationLevelEnum|null $isolationLevel + * @return void + * @throws InvalidArgumentException + * @throws RepositoryReadOnlyException + * @throws Throwable + */ + public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel = null): void + { + if (empty($queries)) { + throw new InvalidArgumentException('You pass an empty array to bulk'); + } + + $dbDriver = $this->getDbDriverWrite(); + + $dbDriver->beginTransaction($isolationLevel, allowJoin: true); + try { + foreach ($queries as $query) { + if (!($query instanceof QueryBuilderInterface) && !($query instanceof Updatable)) { + throw new InvalidArgumentException('Invalid query type. Expected QueryBuilderInterface or Updatable.'); + } + + // Build SQL object using the write driver/helper to ensure correct dialect + $sqlObject = $query->build($dbDriver); + $dbDriver->execute($sqlObject->getSql(), $sqlObject->getParameters()); + } + $dbDriver->commitTransaction(); + } catch (Throwable $e) { + $dbDriver->rollbackTransaction(); + throw $e; + } + } + /** * @param DeleteQuery $updatable * @return bool @@ -440,7 +479,7 @@ public function addObserver(ObserverProcessorInterface $observerProcessor): void protected function insert(InsertQuery $updatable, mixed $keyGen): mixed { if (empty($keyGen)) { - return $this->insertWithAutoInc($updatable); + return $this->insertWithAutoinc($updatable); } else { $this->insertWithKeyGen($updatable); return null; diff --git a/tests/BulkTest.php b/tests/BulkTest.php new file mode 100644 index 0000000..e16332d --- /dev/null +++ b/tests/BulkTest.php @@ -0,0 +1,144 @@ +dbDriver = Factory::getDbInstance(self::URI); + + // Create table and seed data similar to RepositoryTest + $this->dbDriver->execute('create table users ( + id integer primary key autoincrement, + name varchar(45), + createdate datetime);' + ); + $insertBulk = InsertBulkQuery::getInstance('users', ['name', 'createdate']); + $insertBulk->values(['name' => 'John Doe', 'createdate' => '2017-01-02']); + $insertBulk->values(['name' => 'Jane Doe', 'createdate' => '2017-01-04']); + $insertBulk->values(['name' => 'JG', 'createdate' => '1974-01-26']); + $insertBulk->buildAndExecute($this->dbDriver); + + $mapper = new Mapper(Users::class, 'users', 'Id'); + $this->repository = new Repository($this->dbDriver, $mapper); + } + + protected function tearDown(): void + { + $uri = new Uri(self::URI); + @unlink($uri->getPath()); + } + + public function testBulkMixedQueriesWithParamCollision(): void + { + // Sanity check: count, specific rows + // 3 initial rows + $count = Query::getInstance()->fields(['count(*) as cnt'])->table('users'); + $res = $this->repository->getByQueryRaw($count); + $this->assertEquals(3, (int)$res[0]['cnt']); + + // id=1 should have name 'John Doe' + $row = $this->repository->get(1); + $this->assertEquals('John Doe', $row->getName()); + + // The 'Alice' should not exist + $rows = $this->repository->getByFilter('name = :name', ['name' => 'Alice']); + $this->assertCount(0, $rows); + + // The 'JG' should exist before bulk + $rows = $this->repository->getByFilter('name = :name', ['name' => 'JG']); + $this->assertCount(1, $rows); + + // Prepare queries with overlapping parameter names (:name used in multiple queries) + $insert = InsertQuery::getInstance('users', [ + 'name' => 'Alice', + 'createdate' => '2020-01-01' + ]); + + $update = UpdateQuery::getInstance() + ->table('users') + ->set('name', 'Bob') + ->where('id = :id', ['id' => 1]); + + $delete = DeleteQuery::getInstance() + ->table('users') + ->where('name = :name', ['name' => 'JG']); + + // Execute bulk + $this->repository->bulkExecute([$insert, $update, $delete], null); + + // Validate results: count, specific rows + // 3 initial + 1 insert - 1 delete = 3 rows + $count = Query::getInstance()->fields(['count(*) as cnt'])->table('users'); + $res = $this->repository->getByQueryRaw($count); + $this->assertEquals(3, (int)$res[0]['cnt']); + + // id=1 should now have name 'Bob' + $row = $this->repository->get(1); + $this->assertEquals('Bob', $row->getName()); + + // The newly inserted 'Alice' should exist + $rows = $this->repository->getByFilter('name = :name', ['name' => 'Alice']); + $this->assertCount(1, $rows); + + // The deleted 'JG' should be gone + $rows = $this->repository->getByFilter('name = :name', ['name' => 'JG']); + $this->assertCount(0, $rows); + } + + public function testBulkEmptyArray(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You pass an empty array to bulk'); + + // Should not throw and not change anything + $this->repository->bulkExecute([], null); + } + + public function testBulkMixedQueriesWithInvalidQuery(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid query type. Expected QueryBuilderInterface or Updatable.'); + + // Prepare queries with overlapping parameter names (:name used in multiple queries) + $insert = InsertQuery::getInstance('users', [ + 'name' => 'Alice', + 'createdate' => '2020-01-01' + ]); + + $update = UpdateQuery::getInstance() + ->table('users') + ->set('name', 'Bob') + ->where('id = :id', ['id' => 1]); + + $delete = DeleteQuery::getInstance() + ->table('users') + ->where('name = :name', ['name' => 'JG']); + + $invalid = "invalid"; + + // Execute bulk + $this->repository->bulkExecute([$insert, $invalid, $update, $delete], null); + } + +} From aad9102d5768ba3823880bcdb0faea305e78a592 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Wed, 3 Sep 2025 13:06:27 -0400 Subject: [PATCH 03/17] Refactor `save` method in `Repository` for improved readability and maintainability Extracted `saveUpdatable` and `saveUpdatableInternal` methods from `save` to modularize functionality. Simplified code structure and reduced duplication in insert and update operations. Enhanced handling of null primary key fields and ensured consistency with mapping operations. --- src/Repository.php | 114 +++++++++++++++++++++++++++++---------------- 1 file changed, 75 insertions(+), 39 deletions(-) diff --git a/src/Repository.php b/src/Repository.php index 8c26e12..c81d5a1 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -361,6 +361,73 @@ public function getByQueryRaw(QueryBuilderInterface $query): array * @throws \ByJG\Serializer\Exception\InvalidArgumentException */ public function save(mixed $instance, UpdateConstraint $updateConstraint = null): mixed + { + // Build the updatable without executing + [$updatable, $array, $fieldToProperty, $isInsert, $oldInstance, $pkList] = $this->saveUpdatableInternal($instance); + + // Execute the Insert or Update + if ($isInsert) { + $keyGen = $this->getMapper()->generateKey($this->getDbDriver(), $instance) ?? []; + if (!empty($keyGen) && !is_array($keyGen)) { + $keyGen = [$keyGen]; + } + $position = 0; + foreach ($keyGen as $value) { + $array[$pkList[$position]] = $value; + $updatable->set($this->mapper->getPrimaryKey()[$position++], $value); + } + $keyReturned = $this->insert($updatable, $keyGen); + if (count($pkList) == 1 && !empty($keyReturned)) { + $array[$pkList[0]] = $keyReturned; + } + } else { + if (!empty($updateConstraint)) { + $updateConstraint->check($oldInstance, $this->getMapper()->getEntity($array)); + } + $this->update($updatable); + } + + ObjectCopy::copy($array, $instance, function ($sourcePropertyName) use ($fieldToProperty) { + return $fieldToProperty[$sourcePropertyName] ?? $sourcePropertyName; + }); + + ORMSubject::getInstance()->notify( + $this->mapper->getTable(), + $isInsert ? ORMSubject::EVENT_INSERT : ORMSubject::EVENT_UPDATE, + $instance, $oldInstance + ); + + return $instance; + } + + /** + * Build and return the updatable (InsertQuery or UpdateQuery) without executing it. + * This method mirrors the preparatory stage of save() and can be used to inspect or + * bulk-compose updates prior to execution. + * + * @param mixed $instance + * @return Updatable + * @throws InvalidArgumentException + * @throws OrmBeforeInvalidException + * @throws RepositoryReadOnlyException + */ + public function saveUpdatable(mixed $instance): Updatable + { + [$updatable] = $this->saveUpdatableInternal($instance); + return $updatable; + } + + /** + * Internal helper that prepares the updatable and returns additional context + * needed by save(). + * + * @param mixed $instance + * @return array [Updatable $updatable, array $array, array $fieldToProperty, bool $isInsert, mixed $oldInstance, array $pkList] + * @throws InvalidArgumentException + * @throws OrmBeforeInvalidException + * @throws RepositoryReadOnlyException + */ + protected function saveUpdatableInternal(mixed $instance): array { // Get all fields $array = Serialize::from($instance) @@ -397,9 +464,11 @@ function ($propName, $targetName, $value) use ($mapper, $instance) { } } else { $fields = array_map(function ($item) use ($array) { - return $array[$item]; + return $array[$item] ?? null; }, $pkList); - $oldInstance = $this->get($fields); + if (!in_array(null, $fields, true)) { + $oldInstance = $this->get($fields); + } } $isInsert = empty($oldInstance); @@ -407,10 +476,10 @@ function ($propName, $targetName, $value) use ($mapper, $instance) { if ($isInsert) { $closure = $this->beforeInsert; $array = $closure($array); - foreach ($this->getMapper()->getFieldMap() as $mapper) { - $fieldValue = $mapper->getInsertFunctionValue($array[$mapper->getFieldName()] ?? null, $instance, $this->getDbDriverWrite()->getDbHelper()); + foreach ($this->getMapper()->getFieldMap() as $mapItem) { + $fieldValue = $mapItem->getInsertFunctionValue($array[$mapItem->getFieldName()] ?? null, $instance, $this->getDbDriverWrite()->getDbHelper()); if ($fieldValue !== false) { - $array[$mapper->getFieldName()] = $fieldValue; + $array[$mapItem->getFieldName()] = $fieldValue; } } $updatable = InsertQuery::getInstance($this->mapper->getTable(), $array); @@ -425,42 +494,9 @@ function ($propName, $targetName, $value) use ($mapper, $instance) { throw new OrmBeforeInvalidException('Invalid Before Insert Closure'); } - // Execute the Insert or Update - if ($isInsert) { - $keyGen = $this->getMapper()->generateKey($this->getDbDriver(), $instance) ?? []; - if (!empty($keyGen) && !is_array($keyGen)) { - $keyGen = [$keyGen]; - } - $position = 0; - foreach ($keyGen as $value) { - $array[$pkList[$position]] = $value; - $updatable->set($this->mapper->getPrimaryKey()[$position++], $value); - } - $keyReturned = $this->insert($updatable, $keyGen); - if (count($pkList) == 1 && !empty($keyReturned)) { - $array[$pkList[0]] = $keyReturned; - } - } else { - if (!empty($updateConstraint)) { - $updateConstraint->check($oldInstance, $this->getMapper()->getEntity($array)); - } - $this->update($updatable); - } - - ObjectCopy::copy($array, $instance, function ($sourcePropertyName) use ($fieldToProperty) { - return $fieldToProperty[$sourcePropertyName] ?? $sourcePropertyName; - }); - - ORMSubject::getInstance()->notify( - $this->mapper->getTable(), - $isInsert ? ORMSubject::EVENT_INSERT : ORMSubject::EVENT_UPDATE, - $instance, $oldInstance - ); - - return $instance; + return [$updatable, $array, $fieldToProperty, $isInsert, $oldInstance, $pkList]; } - /** * @throws InvalidArgumentException */ From 9c9b303b8e265c0b24633d6b3aa71c2aec99a50a Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Wed, 3 Sep 2025 17:04:49 -0400 Subject: [PATCH 04/17] Add support for returning results from `bulkExecute` and introduce `QueryRaw` Updated `bulkExecute` in `Repository` to return results using `GenericIterator`. Introduced `QueryRaw` for executing raw SQL queries. Enhanced `QueryBasic` to handle empty table lists gracefully. Added corresponding unit tests for these features. --- .idea/runConfigurations/PHPUnit.xml | 2 +- README.md | 1 + docs/query-raw.md | 83 +++++++++++++++++++++++++++++ src/QueryBasic.php | 2 +- src/QueryRaw.php | 29 ++++++++++ src/Repository.php | 7 ++- tests/BulkTest.php | 27 +++++++++- 7 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 docs/query-raw.md create mode 100644 src/QueryRaw.php diff --git a/.idea/runConfigurations/PHPUnit.xml b/.idea/runConfigurations/PHPUnit.xml index a5a683e..f81c245 100644 --- a/.idea/runConfigurations/PHPUnit.xml +++ b/.idea/runConfigurations/PHPUnit.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/README.md b/README.md index d29bccf..f834130 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,7 @@ $result = $repository->getByQuery($query); * [Using FieldAlias](docs/using-fieldalias.md) * [Tables without auto increments fields](docs/tables-without-auto-increment-fields.md) * [Using With Recursive SQL Command](docs/using-with-recursive-sql-command.md) +* [QueryRaw (raw SQL)](docs/query-raw.md) ## Install diff --git a/docs/query-raw.md b/docs/query-raw.md new file mode 100644 index 0000000..9f22ee5 --- /dev/null +++ b/docs/query-raw.md @@ -0,0 +1,83 @@ +# QueryRaw + +QueryRaw lets you execute a raw SQL statement while still benefiting from the repository pipeline (parameter binding, +connection handling, iterators, and optional caching when used via SqlStatement). + +Important: QueryRaw does NOT generate or adapt SQL based on the DbDriver/DB dialect. It passes through exactly the SQL +string you provide. That means the SQL you write must already be valid for the target database engine. Because of this, +QueryRaw should be used only for very specific use cases where the high-level Query/Update/Insert/Delete builders cannot +express what you need. + +When to use QueryRaw + +- Vendor-specific features that are not covered by the query builders (e.g., dialect functions, specialized hints). +- One-off statements where you accept tight coupling to a single database dialect. +- Chaining within bulk operations when you need to run a raw select right after a write. + +When NOT to use QueryRaw + +- Everyday selects/joins/filters/limits that can be expressed with Query or Union. +- Inserts/updates/deletes that can be handled by InsertQuery/UpdateQuery/DeleteQuery. +- Anywhere you want portability across databases or automatic dialect handling. + +Behavior + +- build(): returns a SqlObject with your SQL and parameters unchanged. The optional DbDriver parameter is ignored for + SQL generation. +- buildAndGetIterator($dbDriver): executes your SQL against the provided driver and returns a GenericIterator. +- Parameter binding: pass parameters as an array (named or positional) and the underlying driver will bind them. + +Examples + +1) Basic raw select returning rows as arrays + +```php +use ByJG\MicroOrm\QueryRaw; + +$query = QueryRaw::getInstance( + 'select id, name from users where name like :part', + ['part' => 'A%'] +); + +$rows = $repository->getByQueryRaw($query); // array> +``` + +2) Using dialect-specific functions (DB-specific SQL) + +```php +// Example for SQLite using julianday(); not portable to other databases +$query = QueryRaw::getInstance( + "select name, julianday('2020-06-28') - julianday(createdate) as days from users limit 1" +); + +$rows = $repository->getByQueryRaw($query); +// e.g., [ [ 'name' => 'Jane Doe', 'days' => 1271.0 ] ] +``` + +3) Bulk execution: insert followed by selecting last inserted id + +```php +use ByJG\MicroOrm\InsertQuery; +use ByJG\MicroOrm\QueryRaw; + +$insert = InsertQuery::getInstance('users', [ + 'name' => 'Charlie', + 'createdate' => '2025-01-01', +]); + +// Use DB helper to get the correct SQL for your driver +$selectLastId = QueryRaw::getInstance( + $repository->getDbDriver()->getDbHelper()->getSqlLastInsertId() +); + +$it = $repository->bulkExecute([$insert, $selectLastId]); +$result = $it->toArray(); +// $result is the rows from the last statement (the select) +``` + +Notes + +- QueryRaw ties your code to the specific SQL dialect you write. If you switch databases, you may need to rewrite the + raw SQL. +- Always use bound parameters to avoid SQL injection; pass them in the second argument to getInstance(). +- For portable queries, prefer Query/Union and the provided Insert/Update/Delete builders. diff --git a/src/QueryBasic.php b/src/QueryBasic.php index 8c458df..97ff974 100644 --- a/src/QueryBasic.php +++ b/src/QueryBasic.php @@ -272,7 +272,7 @@ public function build(?DbDriverInterface $dbDriver = null): SqlObject $sql .= "SELECT " . ($this->distinct ? "DISTINCT " : "") . $fieldList . - "FROM " . $tableList; + (!empty($tableList) ? "FROM " . $tableList : ""); $whereStr = $this->getWhere(); if (!is_null($whereStr)) { diff --git a/src/QueryRaw.php b/src/QueryRaw.php new file mode 100644 index 0000000..2a3adf8 --- /dev/null +++ b/src/QueryRaw.php @@ -0,0 +1,29 @@ +sql, $this->parameters); + } + + public function buildAndGetIterator(?DbDriverInterface $dbDriver = null, ?CacheQueryResult $cache = null): GenericIterator + { + return $dbDriver->getIterator($this->sql, $this->parameters); + } +} \ No newline at end of file diff --git a/src/Repository.php b/src/Repository.php index c81d5a1..71a5039 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -3,6 +3,7 @@ namespace ByJG\MicroOrm; use ByJG\AnyDataset\Core\Enum\Relation; +use ByJG\AnyDataset\Core\GenericIterator; use ByJG\AnyDataset\Core\IteratorFilter; use ByJG\AnyDataset\Db\DbDriverInterface; use ByJG\AnyDataset\Db\IsolationLevelEnum; @@ -201,7 +202,7 @@ public function delete(array|string|int|LiteralInterface $pkId): bool * @throws RepositoryReadOnlyException * @throws Throwable */ - public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel = null): void + public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel = null): ?GenericIterator { if (empty($queries)) { throw new InvalidArgumentException('You pass an empty array to bulk'); @@ -209,6 +210,7 @@ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel $dbDriver = $this->getDbDriverWrite(); + $it = null; $dbDriver->beginTransaction($isolationLevel, allowJoin: true); try { foreach ($queries as $query) { @@ -218,13 +220,14 @@ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel // Build SQL object using the write driver/helper to ensure correct dialect $sqlObject = $query->build($dbDriver); - $dbDriver->execute($sqlObject->getSql(), $sqlObject->getParameters()); + $it = $dbDriver->getIterator($sqlObject->getSql(), $sqlObject->getParameters()); } $dbDriver->commitTransaction(); } catch (Throwable $e) { $dbDriver->rollbackTransaction(); throw $e; } + return $it; } /** diff --git a/tests/BulkTest.php b/tests/BulkTest.php index e16332d..d8d6a00 100644 --- a/tests/BulkTest.php +++ b/tests/BulkTest.php @@ -10,6 +10,7 @@ use ByJG\MicroOrm\InsertQuery; use ByJG\MicroOrm\Mapper; use ByJG\MicroOrm\Query; +use ByJG\MicroOrm\QueryRaw; use ByJG\MicroOrm\Repository; use ByJG\MicroOrm\UpdateQuery; use ByJG\Util\Uri; @@ -85,7 +86,8 @@ public function testBulkMixedQueriesWithParamCollision(): void ->where('name = :name', ['name' => 'JG']); // Execute bulk - $this->repository->bulkExecute([$insert, $update, $delete], null); + $it = $this->repository->bulkExecute([$insert, $update, $delete], null); + $this->assertEquals([], $it->toArray()); // Validate results: count, specific rows // 3 initial + 1 insert - 1 delete = 3 rows @@ -141,4 +143,27 @@ public function testBulkMixedQueriesWithInvalidQuery(): void $this->repository->bulkExecute([$insert, $invalid, $update, $delete], null); } + public function testBulkInsertAndSelectLastInsertedId(): void + { + // initial rows are 3; next autoincrement id should be 4 after insert + $insert = InsertQuery::getInstance('users', [ + 'name' => 'Charlie', + 'createdate' => '2025-01-01' + ]); + + // Outer query selects last_insert_rowid() from the single-row subquery + $selectLastId = QueryRaw::getInstance($this->repository->getDbDriver()->getDbHelper()->getSqlLastInsertId()); + + $it = $this->repository->bulkExecute([$insert, $selectLastId], null); + $result = $it->toArray(); + + $this->assertCount(1, $result); + // SQLite returns integer for last_insert_rowid(); expect 4 here + $this->assertEquals(4, (int)$result[0]['id']); + + // Also ensure the inserted row exists + $rows = $this->repository->getByFilter('name = :name', ['name' => 'Charlie']); + $this->assertCount(1, $rows); + $this->assertEquals('Charlie', $rows[0]->getName()); + } } From ba86f58283ead0b3e05b321d1de79ce6df328d57 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Wed, 3 Sep 2025 17:13:32 -0400 Subject: [PATCH 05/17] Fix Psalm --- src/Repository.php | 2 +- tests/BulkTest.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Repository.php b/src/Repository.php index 71a5039..f91c184 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -197,7 +197,7 @@ public function delete(array|string|int|LiteralInterface $pkId): bool * * @param array $queries List of queries to be executed in bulk * @param IsolationLevelEnum|null $isolationLevel - * @return void + * @return GenericIterator|null * @throws InvalidArgumentException * @throws RepositoryReadOnlyException * @throws Throwable diff --git a/tests/BulkTest.php b/tests/BulkTest.php index d8d6a00..0efed08 100644 --- a/tests/BulkTest.php +++ b/tests/BulkTest.php @@ -140,6 +140,7 @@ public function testBulkMixedQueriesWithInvalidQuery(): void $invalid = "invalid"; // Execute bulk + /** @psalm-suppress InvalidArgument */ $this->repository->bulkExecute([$insert, $invalid, $update, $delete], null); } From 447733f5d76050458c619062f4e1dc4ace92fb71 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Wed, 3 Sep 2025 20:21:31 -0400 Subject: [PATCH 06/17] Optimize `bulkExecute` in `Repository` to support combined query execution Refactored `bulkExecute` to concatenate multiple queries into a single SQL string with renamed parameters, avoiding collisions. Enhanced efficiency by reducing transaction overhead and improving parameter handling for better execution performance. --- src/Repository.php | 58 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/src/Repository.php b/src/Repository.php index f91c184..88d914b 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -210,24 +210,52 @@ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel $dbDriver = $this->getDbDriverWrite(); - $it = null; - $dbDriver->beginTransaction($isolationLevel, allowJoin: true); - try { - foreach ($queries as $query) { - if (!($query instanceof QueryBuilderInterface) && !($query instanceof Updatable)) { - throw new InvalidArgumentException('Invalid query type. Expected QueryBuilderInterface or Updatable.'); - } + $bigSql = ''; + $bigParams = []; + $index = 0; + + foreach ($queries as $query) { + if (!($query instanceof QueryBuilderInterface) && !($query instanceof Updatable)) { + // Ignore invalid entries silently; could throw InvalidArgumentException if desired + continue; + } - // Build SQL object using the write driver/helper to ensure correct dialect - $sqlObject = $query->build($dbDriver); - $it = $dbDriver->getIterator($sqlObject->getSql(), $sqlObject->getParameters()); + // Build SQL object using the write driver to ensure correct helper/dialect + $sqlObject = $query->build($dbDriver); + $sql = $sqlObject->getSql(); + $params = $sqlObject->getParameters(); + + // Rename parameters to avoid collisions across statements + if (!empty($params)) { + $renamedParams = []; + foreach ($params as $name => $value) { + $newName = $name . '_b' . $index; + // Replace both literal-style [[name]] and normal :name placeholders + $sql = preg_replace( + [ + "/\\[\\[$name]]/", + "/:$name(\\W|$)/" + ], + [ + "[[{$newName}]]", + ":{$newName}$1" + ], + $sql + ); + $renamedParams[$newName] = $value; + } + $params = $renamedParams; } - $dbDriver->commitTransaction(); - } catch (Throwable $e) { - $dbDriver->rollbackTransaction(); - throw $e; + + // Append to the big SQL string + $bigSql .= rtrim($sql, "; \t\n\r\0\x0B") . ";\n"; + // Merge parameters + $bigParams = array_merge($bigParams, $params); + + $index++; } - return $it; + + return $dbDriver->getIterator($bigSql, $bigParams); } /** From 1ed0183e4b35754e86b372e165a71247d75ae7f6 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Wed, 3 Sep 2025 20:37:01 -0400 Subject: [PATCH 07/17] Enhance `bulkExecute` to inline parameters and support multi-statement execution with trailing SELECT handling --- src/Repository.php | 63 ++++++++++++++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 22 deletions(-) diff --git a/src/Repository.php b/src/Repository.php index 88d914b..c3dd48d 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -9,6 +9,7 @@ use ByJG\AnyDataset\Db\IsolationLevelEnum; use ByJG\AnyDataset\Db\IteratorFilterSqlFormatter; use ByJG\AnyDataset\Db\SqlStatement; +use ByJG\AnyDataset\Lists\ArrayDataset; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\Exception\OrmBeforeInvalidException; use ByJG\MicroOrm\Exception\OrmInvalidFieldsException; @@ -209,15 +210,14 @@ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel } $dbDriver = $this->getDbDriverWrite(); + $pdo = $dbDriver->getDbConnection(); - $bigSql = ''; - $bigParams = []; - $index = 0; + $bigSqlWrites = ''; + $selectSql = null; - foreach ($queries as $query) { + foreach ($queries as $i => $query) { if (!($query instanceof QueryBuilderInterface) && !($query instanceof Updatable)) { - // Ignore invalid entries silently; could throw InvalidArgumentException if desired - continue; + throw new InvalidArgumentException('Invalid query type. Expected QueryBuilderInterface or Updatable.'); } // Build SQL object using the write driver to ensure correct helper/dialect @@ -225,37 +225,56 @@ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel $sql = $sqlObject->getSql(); $params = $sqlObject->getParameters(); - // Rename parameters to avoid collisions across statements + // Inline parameters directly into SQL to avoid multi-statement binding issues if (!empty($params)) { - $renamedParams = []; foreach ($params as $name => $value) { - $newName = $name . '_b' . $index; - // Replace both literal-style [[name]] and normal :name placeholders + $replacement = 'NULL'; + if (!is_null($value)) { + if (is_bool($value)) { + $replacement = $value ? '1' : '0'; + } elseif (is_int($value) || is_float($value)) { + $replacement = (string)$value; + } else { + // Strings and others: use PDO quote if available, otherwise fallback to single-quoted with basic escaping + $quoted = method_exists($pdo, 'quote') && $pdo ? $pdo->quote((string)$value) : ("'" . str_replace("'", "''", (string)$value) . "'"); + $replacement = $quoted; + } + } + + // Replace occurrences of the parameter (not preceded by another ':') $sql = preg_replace( [ - "/\\[\\[$name]]/", - "/:$name(\\W|$)/" + "/(?exec($bigSqlWrites); + } + + // If there is a trailing SELECT, fetch it and return its iterator. Otherwise return an empty iterator + if (!empty($selectSql)) { + return $dbDriver->getIterator($selectSql); } - return $dbDriver->getIterator($bigSql, $bigParams); + return (new ArrayDataset([]))->getIterator(); } /** From 617e00d1b88b24cd85e0ce1222111b9b326daf6b Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 4 Sep 2025 06:12:26 -0400 Subject: [PATCH 08/17] Enhance `bulkExecute` to inline parameters and support multi-statement execution with trailing SELECT handling --- src/Repository.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Repository.php b/src/Repository.php index c3dd48d..afd43ce 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -236,7 +236,7 @@ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel $replacement = (string)$value; } else { // Strings and others: use PDO quote if available, otherwise fallback to single-quoted with basic escaping - $quoted = method_exists($pdo, 'quote') && $pdo ? $pdo->quote((string)$value) : ("'" . str_replace("'", "''", (string)$value) . "'"); + $quoted = method_exists($pdo, 'quote') ? $pdo->quote((string)$value) : ("'" . str_replace("'", "''", (string)$value) . "'"); $replacement = $quoted; } } From 213967cae165b14c0756dd8d97ed3056e2fd2ec8 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 4 Sep 2025 13:48:36 -0400 Subject: [PATCH 09/17] Switch database configuration and tests from SQLite to MySQL, introduce Docker Compose for MySQL service, and update repository logic to handle database-specific nuances. --- .github/workflows/phpunit.yml | 14 ++ .idea/runConfigurations/MySQL_Server_Test.xml | 10 + docker-compose.yml | 13 ++ src/Repository.php | 62 +++--- tests/BulkTest.php | 10 +- tests/Model/UsersWithUuidKey.php | 4 +- tests/RepositoryAliasTest.php | 10 +- tests/RepositoryPkListTest.php | 8 +- tests/RepositoryTest.php | 202 ++++++++++-------- tests/RepositoryUuidTest.php | 8 +- tests/TransactionManagerTest.php | 79 +++---- 11 files changed, 234 insertions(+), 186 deletions(-) create mode 100644 .idea/runConfigurations/MySQL_Server_Test.xml create mode 100644 docker-compose.yml diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index b65df91..82778f4 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -20,6 +20,20 @@ jobs: - "8.2" - "8.1" + services: + mysql: + image: mysql:8.0.20 + env: + MYSQL_ROOT_PASSWORD: password + MYSQL_AUTHENTICATION_PLUGIN: mysql_native_password + ports: + - "3306:3306" + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=20s + --health-retries=10 + steps: - uses: actions/checkout@v4 - run: composer install diff --git a/.idea/runConfigurations/MySQL_Server_Test.xml b/.idea/runConfigurations/MySQL_Server_Test.xml new file mode 100644 index 0000000..04f7a1e --- /dev/null +++ b/.idea/runConfigurations/MySQL_Server_Test.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..072f693 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + mysql: + image: mysql:8.0.20 + environment: + - MYSQL_ROOT_PASSWORD=password + - MYSQL_AUTHENTICATION_PLUGIN=mysql_native_password + ports: + - "3306:3306" + healthcheck: + test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ] + timeout: 20s + interval: 10s + retries: 10 diff --git a/src/Repository.php b/src/Repository.php index afd43ce..17b06e6 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -214,6 +214,8 @@ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel $bigSqlWrites = ''; $selectSql = null; + $selectParams = []; + $bigParams = []; foreach ($queries as $i => $query) { if (!($query instanceof QueryBuilderInterface) && !($query instanceof Updatable)) { @@ -224,54 +226,48 @@ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel $sqlObject = $query->build($dbDriver); $sql = $sqlObject->getSql(); $params = $sqlObject->getParameters(); + $isSelect = str_starts_with(strtoupper(ltrim($sql)), 'SELECT'); + + if ($isSelect && $i === array_key_last($queries)) { + // Trailing SELECT: keep it separate with its own params + $selectSql = rtrim($sql, "; \t\n\r\0\x0B"); + $selectParams = $params ?? []; + continue; + } - // Inline parameters directly into SQL to avoid multi-statement binding issues + // For write statements, avoid parameter name collisions by uniquifying named params if (!empty($params)) { - foreach ($params as $name => $value) { - $replacement = 'NULL'; - if (!is_null($value)) { - if (is_bool($value)) { - $replacement = $value ? '1' : '0'; - } elseif (is_int($value) || is_float($value)) { - $replacement = (string)$value; - } else { - // Strings and others: use PDO quote if available, otherwise fallback to single-quoted with basic escaping - $quoted = method_exists($pdo, 'quote') ? $pdo->quote((string)$value) : ("'" . str_replace("'", "''", (string)$value) . "'"); - $replacement = $quoted; - } + $newParams = []; + foreach ($params as $key => $value) { + // Only process named parameters (string keys) + if (is_string($key)) { + $uniqueKey = $key . '__b' . $i; + // Replace ":key" with ":key__b{i}" using a safe regex that avoids partial matches + $pattern = '/(?exec($bigSqlWrites); + $dbDriver->execute($bigSqlWrites, $bigParams); } // If there is a trailing SELECT, fetch it and return its iterator. Otherwise return an empty iterator if (!empty($selectSql)) { - return $dbDriver->getIterator($selectSql); + return $dbDriver->getIterator($selectSql, $selectParams); } return (new ArrayDataset([]))->getIterator(); diff --git a/tests/BulkTest.php b/tests/BulkTest.php index 0efed08..a772ce3 100644 --- a/tests/BulkTest.php +++ b/tests/BulkTest.php @@ -13,13 +13,12 @@ use ByJG\MicroOrm\QueryRaw; use ByJG\MicroOrm\Repository; use ByJG\MicroOrm\UpdateQuery; -use ByJG\Util\Uri; use PHPUnit\Framework\TestCase; use Tests\Model\Users; class BulkTest extends TestCase { - const URI = 'sqlite:///tmp/test-bulk.db'; + const URI = 'mysql://root:password@127.0.0.1'; protected DbDriverInterface $dbDriver; protected Repository $repository; @@ -27,10 +26,12 @@ class BulkTest extends TestCase protected function setUp(): void { $this->dbDriver = Factory::getDbInstance(self::URI); + $this->dbDriver->execute('create database if not exists testmicroorm;'); + $this->dbDriver = Factory::getDbInstance(self::URI . '/testmicroorm'); // Create table and seed data similar to RepositoryTest $this->dbDriver->execute('create table users ( - id integer primary key autoincrement, + id integer primary key auto_increment, name varchar(45), createdate datetime);' ); @@ -46,8 +47,7 @@ protected function setUp(): void protected function tearDown(): void { - $uri = new Uri(self::URI); - @unlink($uri->getPath()); + $this->dbDriver->execute('drop table users'); } public function testBulkMixedQueriesWithParamCollision(): void diff --git a/tests/Model/UsersWithUuidKey.php b/tests/Model/UsersWithUuidKey.php index 1ac1911..76097c5 100644 --- a/tests/Model/UsersWithUuidKey.php +++ b/tests/Model/UsersWithUuidKey.php @@ -3,10 +3,10 @@ namespace Tests\Model; use ByJG\MicroOrm\Attributes\FieldAttribute; -use ByJG\MicroOrm\Attributes\TableSqliteUuidPKAttribute; +use ByJG\MicroOrm\Attributes\TableMySqlUuidPKAttribute; use ByJG\MicroOrm\Literal\Literal; -#[TableSqliteUuidPKAttribute(tableName: 'usersuuid')] +#[TableMySqlUuidPKAttribute(tableName: 'usersuuid')] class UsersWithUuidKey { #[FieldAttribute(primaryKey: true)] diff --git a/tests/RepositoryAliasTest.php b/tests/RepositoryAliasTest.php index 5fab58c..86a864c 100644 --- a/tests/RepositoryAliasTest.php +++ b/tests/RepositoryAliasTest.php @@ -8,14 +8,13 @@ use ByJG\MicroOrm\Mapper; use ByJG\MicroOrm\Query; use ByJG\MicroOrm\Repository; -use ByJG\Util\Uri; use PHPUnit\Framework\TestCase; use Tests\Model\Customer; class RepositoryAliasTest extends TestCase { - const URI='sqlite:///tmp/teste.db'; + const URI = 'mysql://root:password@127.0.0.1'; /** * @var Mapper @@ -35,9 +34,11 @@ class RepositoryAliasTest extends TestCase public function setUp(): void { $this->dbDriver = Factory::getDbInstance(self::URI); + $this->dbDriver->execute('create database if not exists testmicroorm;'); + $this->dbDriver = Factory::getDbInstance(self::URI . "/testmicroorm"); $this->dbDriver->execute('create table customers ( - id integer primary key autoincrement, + id integer primary key auto_increment, customer_name varchar(45), customer_age int);' ); @@ -58,8 +59,7 @@ public function setUp(): void public function tearDown(): void { - $uri = new Uri(self::URI); - unlink($uri->getPath()); + $this->dbDriver->execute('drop table if exists customers;'); } public function testGet() diff --git a/tests/RepositoryPkListTest.php b/tests/RepositoryPkListTest.php index 9a3410d..59bbbd9 100644 --- a/tests/RepositoryPkListTest.php +++ b/tests/RepositoryPkListTest.php @@ -6,14 +6,13 @@ use ByJG\AnyDataset\Db\Factory; use ByJG\MicroOrm\Mapper; use ByJG\MicroOrm\Repository; -use ByJG\Util\Uri; use PHPUnit\Framework\TestCase; use Tests\Model\Items; class RepositoryPkListTest extends TestCase { - const URI='sqlite:///tmp/teste.db'; + const URI = 'mysql://root:password@127.0.0.1'; /** * @var Mapper @@ -33,6 +32,8 @@ class RepositoryPkListTest extends TestCase public function setUp(): void { $this->dbDriver = Factory::getDbInstance(self::URI); + $this->dbDriver->execute('create database if not exists testmicroorm;'); + $this->dbDriver = Factory::getDbInstance(self::URI . "/testmicroorm"); $this->dbDriver->execute('CREATE TABLE items ( storeid INTEGER, @@ -52,8 +53,7 @@ public function setUp(): void public function tearDown(): void { - $uri = new Uri(self::URI); - unlink($uri->getPath()); + $this->dbDriver->execute('drop table if exists items;'); } public function testGet() diff --git a/tests/RepositoryTest.php b/tests/RepositoryTest.php index 627d825..1939beb 100644 --- a/tests/RepositoryTest.php +++ b/tests/RepositoryTest.php @@ -30,7 +30,6 @@ use ByJG\MicroOrm\Union; use ByJG\MicroOrm\UpdateConstraint; use ByJG\MicroOrm\UpdateQuery; -use ByJG\Util\Uri; use DateTime; use Exception; use PHPUnit\Framework\ExpectationFailedException; @@ -46,7 +45,7 @@ class RepositoryTest extends TestCase { - const URI='sqlite:///tmp/test.db'; + const URI = 'mysql://root:password@127.0.0.1'; /** * @var Mapper @@ -71,9 +70,11 @@ class RepositoryTest extends TestCase public function setUp(): void { $this->dbDriver = Factory::getDbInstance(self::URI); + $this->dbDriver->execute('create database if not exists testmicroorm;'); + $this->dbDriver = Factory::getDbInstance(self::URI . "/testmicroorm"); $this->dbDriver->execute('create table users ( - id integer primary key autoincrement, + id integer primary key auto_increment, name varchar(45), createdate datetime);' ); @@ -86,11 +87,11 @@ public function setUp(): void $this->dbDriver->execute('create table info ( - id integer primary key autoincrement, + id integer primary key auto_increment, iduser INTEGER, - property number(10,2), - created_at datetime, - updated_at datetime, + property decimal(10, 2), + created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted_at datetime);' ); $insertMultiple = InsertBulkQuery::getInstance('info', ['iduser', 'property']); @@ -107,8 +108,8 @@ public function setUp(): void public function tearDown(): void { - $uri = new Uri(self::URI); - unlink($uri->getPath()); + $this->dbDriver->execute('drop table if exists users;'); + $this->dbDriver->execute('drop table if exists info;'); ORM::clearRelationships(); } @@ -117,17 +118,17 @@ public function testGet() $users = $this->repository->get(1); $this->assertEquals(1, $users->getId()); $this->assertEquals('John Doe', $users->getName()); - $this->assertEquals('2017-01-02', $users->getCreatedate()); + $this->assertEquals('2017-01-02 00:00:00', $users->getCreatedate()); $users = $this->repository->get("2"); $this->assertEquals(2, $users->getId()); $this->assertEquals('Jane Doe', $users->getName()); - $this->assertEquals('2017-01-04', $users->getCreatedate()); + $this->assertEquals('2017-01-04 00:00:00', $users->getCreatedate()); $users = $this->repository->get(new Literal(1)); $this->assertEquals(1, $users->getId()); $this->assertEquals('John Doe', $users->getName()); - $this->assertEquals('2017-01-02', $users->getCreatedate()); + $this->assertEquals('2017-01-02 00:00:00', $users->getCreatedate()); } public function testGetByFilter() @@ -136,7 +137,7 @@ public function testGetByFilter() $this->assertCount(1, $users); $this->assertEquals(1, $users[0]->getId()); $this->assertEquals('John Doe', $users[0]->getName()); - $this->assertEquals('2017-01-02', $users[0]->getCreatedate()); + $this->assertEquals('2017-01-02 00:00:00', $users[0]->getCreatedate()); $filter = new IteratorFilter(); $filter->and('id', Relation::EQUAL, 2); @@ -144,7 +145,7 @@ public function testGetByFilter() $this->assertCount(1, $users); $this->assertEquals(2, $users[0]->getId()); $this->assertEquals('Jane Doe', $users[0]->getName()); - $this->assertEquals('2017-01-04', $users[0]->getCreatedate()); + $this->assertEquals('2017-01-04 00:00:00', $users[0]->getCreatedate()); } public function testGetSelectFunction() @@ -168,14 +169,14 @@ public function testGetSelectFunction() $users = $this->repository->get(1); $this->assertEquals(1, $users->getId()); - $this->assertEquals('[JOHN DOE] - 2017-01-02', $users->getName()); - $this->assertEquals('2017-01-02', $users->getCreatedate()); + $this->assertEquals('[JOHN DOE] - 2017-01-02 00:00:00', $users->getName()); + $this->assertEquals('2017-01-02 00:00:00', $users->getCreatedate()); $this->assertEquals(2017, $users->getYear()); $users = $this->repository->get(2); $this->assertEquals(2, $users->getId()); - $this->assertEquals('[JANE DOE] - 2017-01-04', $users->getName()); - $this->assertEquals('2017-01-04', $users->getCreatedate()); + $this->assertEquals('[JANE DOE] - 2017-01-04 00:00:00', $users->getName()); + $this->assertEquals('2017-01-04 00:00:00', $users->getCreatedate()); $this->assertEquals(2017, $users->getYear()); } @@ -237,7 +238,7 @@ public function testInsert() $this->assertEquals(4, $users2->getId()); $this->assertEquals('Bla99991919', $users2->getName()); - $this->assertEquals('2015-08-09', $users2->getCreatedate()); + $this->assertEquals('2015-08-09 00:00:00', $users2->getCreatedate()); } public function testInsertReadOnly() @@ -272,7 +273,7 @@ public function testInsert_beforeInsert() $this->assertEquals(4, $users2->getId()); $this->assertEquals('Bla-add', $users2->getName()); - $this->assertEquals('2017-12-21', $users2->getCreatedate()); + $this->assertEquals('2017-12-21 00:00:00', $users2->getCreatedate()); } public function testInsertLiteral() @@ -280,7 +281,7 @@ public function testInsertLiteral() /** @var Users $users */ $users = $this->repository->entity([ "name" => new Literal("X'6565'"), - "createdate" => '2015-08-09' + "createdate" => '2015-08-09 12:13:14' ]); $this->assertEquals(null, $users->getId()); @@ -290,7 +291,7 @@ public function testInsertLiteral() $this->assertEquals(4, $users2->getId()); $this->assertEquals('ee', $users2->getName()); - $this->assertEquals('2015-08-09', $users2->getCreatedate()); + $this->assertEquals('2015-08-09 12:13:14', $users2->getCreatedate()); $this->assertEquals($users2->getId(), $users->getId()); $this->assertEquals($users2->getCreatedate(), $users->getCreatedate()); @@ -319,7 +320,7 @@ public function testInsertFromObject() $this->assertEquals(4, $users2->getId()); $this->assertEquals('ee', $users2->getName()); - $this->assertEquals('2015-08-09', $users2->getCreatedate()); + $this->assertEquals('2015-08-09 00:00:00', $users2->getCreatedate()); } public function testInsertKeyGen() @@ -345,7 +346,7 @@ public function testInsertKeyGen() $this->assertEquals(50, $users2->getId()); $this->assertEquals('Bla99991919', $users2->getName()); - $this->assertEquals('2015-08-09', $users2->getCreatedate()); + $this->assertEquals('2015-08-09 00:00:00', $users2->getCreatedate()); } public function testInsertUpdateFunction() @@ -379,7 +380,7 @@ public function testInsertUpdateFunction() $users2 = $this->repository->get(4); $this->assertEquals(4, $users2->getId()); - $this->assertEquals('2015-08-09', $users2->getCreatedate()); + $this->assertEquals('2015-08-09 00:00:00', $users2->getCreatedate()); $this->assertEquals(2015, $users2->getYear()); $this->assertEquals('Sr. John Doe - 2015-08-09', $users2->getName()); } @@ -395,12 +396,12 @@ public function testUpdate() $users2 = $this->repository->get(1); $this->assertEquals(1, $users2->getId()); $this->assertEquals('New Name', $users2->getName()); - $this->assertEquals('2016-01-09', $users2->getCreatedate()); + $this->assertEquals('2016-01-09 00:00:00', $users2->getCreatedate()); $users2 = $this->repository->get(2); $this->assertEquals(2, $users2->getId()); $this->assertEquals('Jane Doe', $users2->getName()); - $this->assertEquals('2017-01-04', $users2->getCreatedate()); + $this->assertEquals('2017-01-04 00:00:00', $users2->getCreatedate()); } public function testUpdateReadOnly() @@ -446,7 +447,7 @@ public function testInsertBuildAndExecute() $users = $this->repository->get(4); $this->assertEquals('inserted name', $users->getName()); - $this->assertEquals('2024-09-03', $users->getCreatedate()); + $this->assertEquals('2024-09-03 00:00:00', $users->getCreatedate()); } public function testInsertBuildAndExecute2() @@ -463,7 +464,7 @@ public function testInsertBuildAndExecute2() $users = $this->repository->get(4); $this->assertEquals('inserted name', $users->getName()); - $this->assertEquals('2024-09-03', $users->getCreatedate()); + $this->assertEquals('2024-09-03 00:00:00', $users->getCreatedate()); } public function testDeleteBuildAndExecute() @@ -497,12 +498,12 @@ public function testUpdate_beforeUpdate() $users2 = $this->repository->get(1); $this->assertEquals(1, $users2->getId()); $this->assertEquals('New Name-upd', $users2->getName()); - $this->assertEquals('2017-12-21', $users2->getCreatedate()); + $this->assertEquals('2017-12-21 00:00:00', $users2->getCreatedate()); $users2 = $this->repository->get(2); $this->assertEquals(2, $users2->getId()); $this->assertEquals('Jane Doe', $users2->getName()); - $this->assertEquals('2017-01-04', $users2->getCreatedate()); + $this->assertEquals('2017-01-04 00:00:00', $users2->getCreatedate()); } public function testUpdateLiteral() @@ -515,7 +516,7 @@ public function testUpdateLiteral() $this->assertEquals(1, $users2->getId()); $this->assertEquals('ee', $users2->getName()); - $this->assertEquals('2017-01-02', $users2->getCreatedate()); + $this->assertEquals('2017-01-02 00:00:00', $users2->getCreatedate()); } public function testUpdateObject() @@ -541,7 +542,7 @@ public function testUpdateObject() $this->assertEquals(1, $users2->getId()); $this->assertEquals('ee', $users2->getName()); - $this->assertEquals('2020-01-02', $users2->getCreatedate()); + $this->assertEquals('2020-01-02 00:00:00', $users2->getCreatedate()); } @@ -573,13 +574,13 @@ public function testUpdateFunction() $users2 = $this->repository->get(1); $this->assertEquals(1, $users2->getId()); $this->assertEquals('Sr. New Name', $users2->getName()); - $this->assertEquals('2016-01-09', $users2->getCreatedate()); + $this->assertEquals('2016-01-09 00:00:00', $users2->getCreatedate()); $this->assertEquals(2016, $users2->getYear()); $users2 = $this->repository->get(2); $this->assertEquals(2, $users2->getId()); $this->assertEquals('Jane Doe', $users2->getName()); - $this->assertEquals('2017-01-04', $users2->getCreatedate()); + $this->assertEquals('2017-01-04 00:00:00', $users2->getCreatedate()); $this->assertEquals(2017, $users2->getYear()); } @@ -592,7 +593,7 @@ public function testDelete() $users = $this->repository->get(2); $this->assertEquals(2, $users->getId()); $this->assertEquals('Jane Doe', $users->getName()); - $this->assertEquals('2017-01-04', $users->getCreatedate()); + $this->assertEquals('2017-01-04 00:00:00', $users->getCreatedate()); } public function testDeleteReadOnly() @@ -610,7 +611,7 @@ public function testDeleteLiteral() $users = $this->repository->get(2); $this->assertEquals(2, $users->getId()); $this->assertEquals('Jane Doe', $users->getName()); - $this->assertEquals('2017-01-04', $users->getCreatedate()); + $this->assertEquals('2017-01-04 00:00:00', $users->getCreatedate()); } public function testDelete2() @@ -624,7 +625,7 @@ public function testDelete2() $users = $this->repository->get(1); $this->assertEquals(1, $users->getId()); $this->assertEquals('John Doe', $users->getName()); - $this->assertEquals('2017-01-02', $users->getCreatedate()); + $this->assertEquals('2017-01-02 00:00:00', $users->getCreatedate()); $users = $this->repository->get(2); $this->assertEmpty($users); @@ -662,7 +663,7 @@ public function testGetByQueryOne() $infoRepository->save($result[0]); $result = $infoRepository->getByQuery($query); - $this->assertSame('0', (string)$result[0]->getValue()); + $this->assertSame('0.00', (string)$result[0]->getValue()); // Set Null $result[0]->setValue(null); @@ -687,7 +688,7 @@ public function testFilterInOne() $this->assertEquals(2, $result[0]->getId()); $this->assertEquals('Jane Doe', $result[0]->getName()); - $this->assertEquals('2017-01-04', $result[0]->getCreatedate()); + $this->assertEquals('2017-01-04 00:00:00', $result[0]->getCreatedate()); } public function testFilterInTwo() @@ -698,11 +699,11 @@ public function testFilterInTwo() $this->assertEquals(2, $result[0]->getId()); $this->assertEquals('Jane Doe', $result[0]->getName()); - $this->assertEquals('2017-01-04', $result[0]->getCreatedate()); + $this->assertEquals('2017-01-04 00:00:00', $result[0]->getCreatedate()); $this->assertEquals(3, $result[1]->getId()); $this->assertEquals('JG', $result[1]->getName()); - $this->assertEquals('1974-01-26', $result[1]->getCreatedate()); + $this->assertEquals('1974-01-26 00:00:00', $result[1]->getCreatedate()); } /** @@ -755,11 +756,11 @@ public function testJoin() $this->assertEquals(1, $result[0][0]->getId()); $this->assertEquals('John Doe', $result[0][0]->getName()); - $this->assertEquals('2017-01-02', $result[0][0]->getCreatedate()); + $this->assertEquals('2017-01-02 00:00:00', $result[0][0]->getCreatedate()); $this->assertEquals(1, $result[1][0]->getId()); $this->assertEquals('John Doe', $result[1][0]->getName()); - $this->assertEquals('2017-01-02', $result[1][0]->getCreatedate()); + $this->assertEquals('2017-01-02 00:00:00', $result[1][0]->getCreatedate()); // - ------------------ @@ -788,7 +789,7 @@ public function testLeftJoin() $this->assertEquals(2, $result[0][0]->getId()); $this->assertEquals('Jane Doe', $result[0][0]->getName()); - $this->assertEquals('2017-01-04', $result[0][0]->getCreatedate()); + $this->assertEquals('2017-01-04 00:00:00', $result[0][0]->getCreatedate()); // - ------------------ @@ -805,7 +806,7 @@ public function testTop() $this->assertEquals(1, $result[0]->getId()); $this->assertEquals('John Doe', $result[0]->getName()); - $this->assertEquals('2017-01-02', $result[0]->getCreatedate()); + $this->assertEquals('2017-01-02 00:00:00', $result[0]->getCreatedate()); $this->assertEquals(1, count($result)); } @@ -819,7 +820,7 @@ public function testLimit() $this->assertEquals(2, $result[0]->getId()); $this->assertEquals('Jane Doe', $result[0]->getName()); - $this->assertEquals('2017-01-04', $result[0]->getCreatedate()); + $this->assertEquals('2017-01-04 00:00:00', $result[0]->getCreatedate()); $this->assertEquals(1, count($result)); } @@ -829,7 +830,7 @@ public function testQueryRaw() $query = $this->repository->queryInstance() ->fields([ "name", - "julianday('2020-06-28') - julianday(createdate) as days" + "DATEDIFF('2020-06-28', createdate) AS days" ]) ->limit(1, 1); @@ -1234,15 +1235,17 @@ public function testMappingAttribute() $this->assertEquals(3, $result[0]->getPk()); $this->assertEquals(3, $result[0]->iduser); $this->assertEquals(3.5, $result[0]->value); - $this->assertNull($result[0]->getCreatedAt()); // Because it was not set in the initial insert outside the ORM - $this->assertNull($result[0]->getUpdatedAt()); // Because it was not set in the initial insert outside the ORM - $this->assertNull($result[0]->getDeletedAt()); // Because it was not set in the initial insert outside the ORM + $this->assertNotNull($result[0]->getCreatedAt()); + $this->assertNotNull($result[0]->getUpdatedAt()); + $this->assertNull($result[0]->getDeletedAt()); + $this->assertEquals($result[0]->getCreatedAt(), $result[0]->getUpdatedAt()); } public function testMappingAttributeInsert() { $infoRepository = new Repository($this->dbDriver, ModelWithAttributes::class); + // Sanity check $query = new Query(); $query->table($this->infoMapper->getTable()) ->where('iduser = :id', ['id' => 123]) @@ -1250,11 +1253,13 @@ public function testMappingAttributeInsert() $result = $infoRepository->getByQuery($query); $this->assertEmpty($result); + // Add a new record $info = new ModelWithAttributes(); $info->iduser = 123; $info->value = 98.5; $infoRepository->save($info); + // Get the Record and assert the values /** @var ModelWithAttributes[] $result */ $result = $infoRepository->getByQuery($query); $this->assertEquals(count($result), 1); @@ -1266,10 +1271,12 @@ public function testMappingAttributeInsert() $this->assertNotNull($result[0]->getUpdatedAt()); $this->assertNull($result[0]->getDeletedAt()); - // Check if the updated_at works + // save the record again and assert the values sleep(1); $info->value = 99.5; $infoRepository->save($info); + + // Get directly from the databae /** @var ModelWithAttributes[] $result2 */ $result2 = $infoRepository->getByQuery($query); $this->assertEquals(count($result2), 1); @@ -1296,14 +1303,19 @@ public function testMappingAttributeSoftDeleteAndGetByQuery() $this->assertEquals(3, $result[0]->getPk()); $this->assertEquals(3, $result[0]->iduser); $this->assertEquals(3.5, $result[0]->value); - $this->assertNull($result[0]->getCreatedAt()); - $this->assertNull($result[0]->getUpdatedAt()); + $this->assertNotNull($result[0]->getCreatedAt()); + $this->assertNotNull($result[0]->getUpdatedAt()); $this->assertNull($result[0]->getDeletedAt()); $infoRepository->delete(3); $result = $infoRepository->getByQuery($query); $this->assertCount(0, $result); + + $query->unsafe(); + $result = $infoRepository->getByQuery($query); + $this->assertCount(1, $result); + $this->assertNotNull($result[0]->getDeletedAt()); } public function testMappingAttributeSoftDeleteAndGetByFilter() @@ -1317,8 +1329,8 @@ public function testMappingAttributeSoftDeleteAndGetByFilter() $this->assertEquals(3, $result[0]->getPk()); $this->assertEquals(3, $result[0]->iduser); $this->assertEquals(3.5, $result[0]->value); - $this->assertNull($result[0]->getCreatedAt()); - $this->assertNull($result[0]->getUpdatedAt()); + $this->assertNotNull($result[0]->getCreatedAt()); + $this->assertNotNull($result[0]->getUpdatedAt()); $this->assertNull($result[0]->getDeletedAt()); $infoRepository->delete(3); @@ -1336,8 +1348,8 @@ public function testMappingAttributeSoftDeleteAndGetByPK() $this->assertEquals(3, $result->getPk()); $this->assertEquals(3, $result->iduser); $this->assertEquals(3.5, $result->value); - $this->assertNull($result->getCreatedAt()); - $this->assertNull($result->getUpdatedAt()); + $this->assertNotNull($result->getCreatedAt()); + $this->assertNotNull($result->getUpdatedAt()); $this->assertNull($result->getDeletedAt()); $infoRepository->delete(3); @@ -1360,7 +1372,7 @@ public function testQueryInstanceWithModel() $this->assertEquals(3, $result[0]->getId()); $this->assertEquals('JG', $result[0]->getName()); - $this->assertEquals('1974-01-26', $result[0]->getCreatedate()); + $this->assertEquals('1974-01-26 00:00:00', $result[0]->getCreatedate()); } @@ -1449,9 +1461,9 @@ public function testActiveRecordGet() $this->assertEquals(3, $model->getPk()); $this->assertEquals(3, $model->iduser); $this->assertEquals(3.5, $model->value); - $this->assertNull($model->getCreatedAt()); // Because it was not set in the initial insert outside the ORM - $this->assertNull($model->getUpdatedAt()); // Because it was not set in the initial insert outside the ORM - $this->assertNull($model->getDeletedAt()); // Because it was not set in the initial insert outside the ORM + $this->assertNotNull($model->getCreatedAt()); + $this->assertNotNull($model->getUpdatedAt()); + $this->assertNull($model->getDeletedAt()); } public function testActiveRecordRefresh() @@ -1472,9 +1484,9 @@ public function testActiveRecordRefresh() $this->assertEquals(3, $model->getPk()); $this->assertEquals(3, $model->iduser); $this->assertEquals(3.5, $model->value); - $this->assertNull($createdAt); // Because it was not set in the initial insert outside the ORM - $this->assertNull($updatedAt); // Because it was not set in the initial insert outside the ORM - $this->assertNull($deletedAt); // Because it was not set in the initial insert outside the ORM + $this->assertNotNull($createdAt); + $this->assertNotNull($updatedAt); + $this->assertNull($deletedAt); // Update the record OUTSIDE the Active Record $this->dbDriver->execute("UPDATE info SET iduser = 4, property = 44.44 WHERE id = 3"); @@ -1483,9 +1495,9 @@ public function testActiveRecordRefresh() $this->assertEquals(3, $model->getPk()); $this->assertEquals(3, $model->iduser); $this->assertEquals(3.5, $model->value); - $this->assertEquals($createdAt, $model->getCreatedAt()); // Because it was not set in the initial insert outside the ORM - $this->assertEquals($updatedAt, $model->getUpdatedAt()); // Because it was not set in the initial insert outside the ORM - $this->assertEquals($deletedAt, $model->getDeletedAt()); // Because it was not set in the initial insert outside the ORM + $this->assertEquals($createdAt, $model->getCreatedAt()); + $this->assertEquals($updatedAt, $model->getUpdatedAt()); + $this->assertEquals($deletedAt, $model->getDeletedAt()); // Refresh the model $model->refresh(); @@ -1494,9 +1506,9 @@ public function testActiveRecordRefresh() $this->assertEquals(3, $model->getPk()); $this->assertEquals(4, $model->iduser); $this->assertEquals(44.44, $model->value); - $this->assertEquals($createdAt, $model->getCreatedAt()); // Because it was not set in the initial insert outside the ORM - $this->assertEquals($updatedAt, $model->getUpdatedAt()); // Because it was not set in the initial insert outside the ORM - $this->assertEquals($deletedAt, $model->getDeletedAt()); // Because it was not set in the initial insert outside the ORM + $this->assertEquals($createdAt, $model->getCreatedAt()); + $this->assertEquals($updatedAt, $model->getUpdatedAt()); + $this->assertEquals($deletedAt, $model->getDeletedAt()); } public function testActiveRecordRefreshError() @@ -1519,9 +1531,9 @@ public function testActiveRecordFill() $this->assertEquals(3, $model->getPk()); $this->assertEquals(3, $model->iduser); $this->assertEquals(3.5, $model->value); - $this->assertNull($model->getCreatedAt()); // Because it was not set in the initial insert outside the ORM - $this->assertNull($model->getUpdatedAt()); // Because it was not set in the initial insert outside the ORM - $this->assertNull($model->getDeletedAt()); // Because it was not set in the initial insert outside the ORM + $this->assertNotNull($model->getCreatedAt()); + $this->assertNotNull($model->getUpdatedAt()); + $this->assertNull($model->getDeletedAt()); $model->fill([ 'iduser' => 4, @@ -1531,9 +1543,9 @@ public function testActiveRecordFill() $this->assertEquals(3, $model->getPk()); $this->assertEquals(4, $model->iduser); $this->assertEquals(44.44, $model->value); - $this->assertNull($model->getCreatedAt()); // Because it was not set in the initial insert outside the ORM - $this->assertNull($model->getUpdatedAt()); // Because it was not set in the initial insert outside the ORM - $this->assertNull($model->getDeletedAt()); // Because it was not set in the initial insert outside the ORM + $this->assertNotNull($model->getCreatedAt()); + $this->assertNotNull($model->getUpdatedAt()); + $this->assertNull($model->getDeletedAt()); } @@ -1547,16 +1559,16 @@ public function testActiveRecordFilter() $this->assertEquals(1, $model[0]->getPk()); $this->assertEquals(1, $model[0]->iduser); $this->assertEquals(30.4, $model[0]->value); - $this->assertNull($model[0]->getCreatedAt()); // Because it was not set in the initial insert outside the ORM - $this->assertNull($model[0]->getUpdatedAt()); // Because it was not set in the initial insert outside the ORM - $this->assertNull($model[0]->getDeletedAt()); // Because it was not set in the initial insert outside the ORM + $this->assertNotNull($model[0]->getCreatedAt()); + $this->assertNotNull($model[0]->getUpdatedAt()); + $this->assertNull($model[0]->getDeletedAt()); $this->assertEquals(2, $model[1]->getPk()); $this->assertEquals(1, $model[1]->iduser); $this->assertEquals(1250.96, $model[1]->value); - $this->assertNull($model[1]->getCreatedAt()); // Because it was not set in the initial insert outside the ORM - $this->assertNull($model[1]->getUpdatedAt()); // Because it was not set in the initial insert outside the ORM - $this->assertNull($model[1]->getDeletedAt()); // Because it was not set in the initial insert outside the ORM + $this->assertNotNull($model[1]->getCreatedAt()); + $this->assertNotNull($model[1]->getUpdatedAt()); + $this->assertNull($model[1]->getDeletedAt()); } public function testActiveRecordEmptyFilter() @@ -1594,17 +1606,17 @@ public function testActiveRecordNew() $this->assertEquals(4, $model->getPk()); $this->assertEquals(5, $model->iduser); $this->assertEquals(55.8, $model->value); - $this->assertNotNull($model->getCreatedAt()); // Because it was not set in the initial insert outside the ORM - $this->assertNotNull($model->getUpdatedAt()); // Because it was not set in the initial insert outside the ORM - $this->assertNull($model->getDeletedAt()); // Because it was not set in the initial insert outside the ORM + $this->assertNotNull($model->getCreatedAt()); + $this->assertNotNull($model->getUpdatedAt()); + $this->assertNull($model->getDeletedAt()); $model = ActiveRecordModel::get(4); $this->assertEquals(4, $model->getPk()); $this->assertEquals(5, $model->iduser); $this->assertEquals(55.8, $model->value); - $this->assertNotNull($model->getCreatedAt()); // Because it was not set in the initial insert outside the ORM - $this->assertNotNull($model->getUpdatedAt()); // Because it was not set in the initial insert outside the ORM - $this->assertNull($model->getDeletedAt()); // Because it was not set in the initial insert outside the ORM + $this->assertNotNull($model->getCreatedAt()); + $this->assertNotNull($model->getUpdatedAt()); + $this->assertNull($model->getDeletedAt()); } public function testActiveRecordUpdate() @@ -1620,17 +1632,17 @@ public function testActiveRecordUpdate() $this->assertEquals(3, $model->getPk()); $this->assertEquals(3, $model->iduser); $this->assertEquals(99.1, $model->value); - $this->assertNull($model->getCreatedAt()); // Because it was not set in the initial insert outside the ORM - $this->assertNotNull($model->getUpdatedAt()); // Because it was not set in the initial insert outside the ORM - $this->assertNull($model->getDeletedAt()); // Because it was not set in the initial insert outside the ORM + $this->assertNotNull($model->getCreatedAt()); + $this->assertNotNull($model->getUpdatedAt()); + $this->assertNull($model->getDeletedAt()); $model = ActiveRecordModel::get(3); $this->assertEquals(3, $model->getPk()); $this->assertEquals(3, $model->iduser); $this->assertEquals(99.1, $model->value); - $this->assertNull($model->getCreatedAt()); // Because it was not set in the initial insert outside the ORM - $this->assertNotNull($model->getUpdatedAt()); // Because it was not set in the initial insert outside the ORM - $this->assertNull($model->getDeletedAt()); // Because it was not set in the initial insert outside the ORM + $this->assertNotNull($model->getCreatedAt()); + $this->assertNotNull($model->getUpdatedAt()); + $this->assertNull($model->getDeletedAt()); } public function testActiveRecordDelete() diff --git a/tests/RepositoryUuidTest.php b/tests/RepositoryUuidTest.php index 93988a2..05eabd9 100644 --- a/tests/RepositoryUuidTest.php +++ b/tests/RepositoryUuidTest.php @@ -6,14 +6,13 @@ use ByJG\AnyDataset\Db\Factory; use ByJG\MicroOrm\Literal\HexUuidLiteral; use ByJG\MicroOrm\Repository; -use ByJG\Util\Uri; use PHPUnit\Framework\TestCase; use Tests\Model\UsersWithUuidKey; class RepositoryUuidTest extends TestCase { - const URI='sqlite:///tmp/test.db'; + const URI = 'mysql://root:password@127.0.0.1'; /** * @var DbDriverInterface @@ -28,6 +27,8 @@ class RepositoryUuidTest extends TestCase public function setUp(): void { $this->dbDriver = Factory::getDbInstance(self::URI); + $this->dbDriver->execute('create database if not exists testmicroorm;'); + $this->dbDriver = Factory::getDbInstance(self::URI . "/testmicroorm"); $this->dbDriver->execute('create table usersuuid ( id binary(16) primary key, @@ -38,8 +39,7 @@ public function setUp(): void public function tearDown(): void { - $uri = new Uri(self::URI); - unlink($uri->getPath()); + $this->dbDriver->execute('drop table if exists usersuuid;'); } public function testGet() diff --git a/tests/TransactionManagerTest.php b/tests/TransactionManagerTest.php index b95328a..1607f58 100644 --- a/tests/TransactionManagerTest.php +++ b/tests/TransactionManagerTest.php @@ -7,11 +7,13 @@ use ByJG\MicroOrm\Mapper; use ByJG\MicroOrm\Repository; use ByJG\MicroOrm\TransactionManager; +use InvalidArgumentException; use PHPUnit\Framework\TestCase; use Tests\Model\Users; class TransactionManagerTest extends TestCase { + const URI = 'mysql://root:password@127.0.0.1'; /** * @var TransactionManager */ @@ -20,41 +22,42 @@ class TransactionManagerTest extends TestCase public function setUp(): void { $this->object = new TransactionManager(); + + $dbDriver = Factory::getDbInstance(self::URI); + $dbDriver->execute('create database if not exists a;'); + $dbDriver->execute('create database if not exists b;'); + $dbDriver->execute('create database if not exists c;'); + $dbDriver->execute('create database if not exists d;'); } public function tearDown(): void { $this->object->destroy(); $this->object = null; - if (file_exists("/tmp/a.db")) { - unlink("/tmp/a.db"); - } - if (file_exists("/tmp/b.db")) { - unlink("/tmp/b.db"); - } - if (file_exists("/tmp/c.db")) { - unlink("/tmp/c.db"); - } - if (file_exists("/tmp/d.db")) { - unlink("/tmp/d.db"); - } + + $dbDriver = Factory::getDbInstance(self::URI . "/a"); + $dbDriver->execute('drop table if exists users;'); + $dbDriver->execute('drop table if exists users1;'); + + $dbDriver = Factory::getDbInstance(self::URI . "/b"); + $dbDriver->execute('drop table if exists users2;'); } public function testAddConnectionError() { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage("The connection already exists with a different instance"); - $dbDrive1 = $this->object->addConnection("sqlite:///tmp/a.db"); - $dbDrive2 = $this->object->addConnection("sqlite:///tmp/b.db"); - $dbDrive3 = $this->object->addConnection("sqlite:///tmp/a.db"); - $dbDrive4 = $this->object->addConnection("sqlite:///tmp/b.db"); + $dbDrive1 = $this->object->addConnection(self::URI . "/a"); + $dbDrive2 = $this->object->addConnection(self::URI . "/b"); + $dbDrive3 = $this->object->addConnection(self::URI . "/a"); + $dbDrive4 = $this->object->addConnection(self::URI . "/b"); } public function testAddConnection() { - $dbDrive1 = $this->object->addConnection("sqlite:///tmp/a.db"); - $dbDrive2 = $this->object->addConnection("sqlite:///tmp/b.db"); + $dbDrive1 = $this->object->addConnection(self::URI . "/a"); + $dbDrive2 = $this->object->addConnection(self::URI . "/b"); $this->object->addDbDriver($dbDrive1); $this->object->addDbDriver($dbDrive2); @@ -65,13 +68,13 @@ public function testAddConnection() public function testAddDbDriverError() { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage("The connection already exists with a different instance"); - $dbDrive1 = Factory::getDbInstance("sqlite:///tmp/a.db"); - $dbDrive2 = Factory::getDbInstance("sqlite:///tmp/b.db"); - $dbDrive3 = Factory::getDbInstance("sqlite:///tmp/a.db"); - $dbDrive4 = Factory::getDbInstance("sqlite:///tmp/b.db"); + $dbDrive1 = Factory::getDbInstance(self::URI . "/a"); + $dbDrive2 = Factory::getDbInstance(self::URI . "/b"); + $dbDrive3 = Factory::getDbInstance(self::URI . "/a"); + $dbDrive4 = Factory::getDbInstance(self::URI . "/b"); $this->object->addDbDriver($dbDrive1); $this->object->addDbDriver($dbDrive2); @@ -81,8 +84,8 @@ public function testAddDbDriverError() public function testAddDbDriver() { - $dbDrive1 = Factory::getDbInstance("sqlite:///tmp/a.db"); - $dbDrive2 = Factory::getDbInstance("sqlite:///tmp/b.db"); + $dbDrive1 = Factory::getDbInstance(self::URI . "/a"); + $dbDrive2 = Factory::getDbInstance(self::URI . "/b"); $this->object->addDbDriver($dbDrive1); $this->object->addDbDriver($dbDrive2); @@ -96,10 +99,10 @@ public function testAddDbDriver() public function testAddRepository() { - $dbDriver = Factory::getDbInstance("sqlite:///tmp/a.db"); + $dbDriver = Factory::getDbInstance(self::URI . "/a"); $dbDriver->execute('create table users ( - id integer primary key autoincrement, + id integer primary key auto_increment, name varchar(45), createdate datetime);' ); @@ -118,8 +121,8 @@ public function testAddRepository() public function testBeginTransaction() { - $this->object->addConnection("sqlite:///tmp/c.db"); - $this->object->addConnection("sqlite:///tmp/d.db"); + $this->object->addConnection(self::URI . "/c"); + $this->object->addConnection(self::URI . "/d"); $this->object->beginTransaction(); $this->object->commitTransaction(); @@ -132,8 +135,8 @@ public function testBeginTransactionTwice() $this->expectException(TransactionException::class); $this->expectExceptionMessage("Transaction Already Started"); - $this->object->addConnection("sqlite:///tmp/a.db"); - $this->object->addConnection("sqlite:///tmp/d.db"); + $this->object->addConnection(self::URI . "/a"); + $this->object->addConnection(self::URI . "/d"); $this->assertEquals(2, $this->object->count()); @@ -146,7 +149,7 @@ public function testRollbackWithNoTransaction() $this->expectException(TransactionException::class); $this->expectExceptionMessage("There is no Active Transaction"); - $this->object->addConnection("sqlite:///tmp/c.db"); + $this->object->addConnection(self::URI . "/c"); $this->assertEquals(1, $this->object->count()); $this->object->rollbackTransaction(); } @@ -156,22 +159,22 @@ public function testCommitWithNoTransaction() $this->expectException(TransactionException::class); $this->expectExceptionMessage("There is no Active Transaction"); - $this->object->addConnection("sqlite:///tmp/d.db"); + $this->object->addConnection(self::URI . "/d"); $this->assertEquals(1, $this->object->count()); $this->object->commitTransaction(); } public function testTransaction() { - $dbDrive1 = Factory::getDbInstance("sqlite:///tmp/a.db"); - $dbDrive2 = Factory::getDbInstance("sqlite:///tmp/b.db"); + $dbDrive1 = Factory::getDbInstance(self::URI . "/a"); + $dbDrive2 = Factory::getDbInstance(self::URI . "/b"); $dbDrive1->execute('create table users1 ( - id integer primary key autoincrement, + id integer primary key auto_increment, name varchar(45));' ); $dbDrive2->execute('create table users2 ( - id integer primary key autoincrement, + id integer primary key auto_increment, name varchar(45));' ); From 7c14ddc2ff9546737f1b03b141e89e49f39ac0d9 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 4 Sep 2025 14:47:36 -0400 Subject: [PATCH 10/17] Refactor test setup to centralize database connection logic with `ConnectionUtil` and optimize transaction handling in `Repository`. --- src/Repository.php | 31 +++++++++++++------ tests/BulkTest.php | 7 +---- tests/ConnectionUtil.php | 29 +++++++++++++++++ tests/RepositoryAliasTest.php | 8 +---- tests/RepositoryPkListTest.php | 8 +---- tests/RepositoryTest.php | 8 +---- tests/RepositoryUuidTest.php | 8 +---- tests/TransactionManagerTest.php | 53 ++++++++++++++------------------ 8 files changed, 78 insertions(+), 74 deletions(-) create mode 100644 tests/ConnectionUtil.php diff --git a/src/Repository.php b/src/Repository.php index 17b06e6..2d4e035 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -23,6 +23,7 @@ use ByJG\Serializer\ObjectCopy; use ByJG\Serializer\Serialize; use Closure; +use Exception; use ReflectionException; use stdClass; use Throwable; @@ -259,18 +260,28 @@ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel $bigSqlWrites .= rtrim($sql, "; \t\n\r\0\x0B") . ";\n"; } - // First execute all writes (if any) in a single batch using direct PDO exec - if (trim($bigSqlWrites) !== '') { - // Use direct PDO to ensure multi-statement execution across drivers like SQLite - $dbDriver->execute($bigSqlWrites, $bigParams); - } + $dbDriver->beginTransaction($isolationLevel, allowJoin: true); + try { + // First execute all writes (if any) in a single batch using direct PDO exec + if (trim($bigSqlWrites) !== '') { + // Use direct PDO to ensure multi-statement execution across drivers like SQLite + $dbDriver->execute($bigSqlWrites, $bigParams); + } - // If there is a trailing SELECT, fetch it and return its iterator. Otherwise return an empty iterator - if (!empty($selectSql)) { - return $dbDriver->getIterator($selectSql, $selectParams); - } + // If there is a trailing SELECT, fetch it and return its iterator. Otherwise return an empty iterator + if (!empty($selectSql)) { + $it = $dbDriver->getIterator($selectSql, $selectParams); + } else { + $it = (new ArrayDataset([]))->getIterator(); + } + + $dbDriver->commitTransaction(); - return (new ArrayDataset([]))->getIterator(); + return $it; + } catch (Exception $ex) { + $dbDriver->rollbackTransaction(); + throw $ex; + } } /** diff --git a/tests/BulkTest.php b/tests/BulkTest.php index a772ce3..5d0f8c1 100644 --- a/tests/BulkTest.php +++ b/tests/BulkTest.php @@ -3,7 +3,6 @@ namespace Tests; use ByJG\AnyDataset\Db\DbDriverInterface; -use ByJG\AnyDataset\Db\Factory; use ByJG\MicroOrm\DeleteQuery; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\InsertBulkQuery; @@ -18,16 +17,12 @@ class BulkTest extends TestCase { - const URI = 'mysql://root:password@127.0.0.1'; - protected DbDriverInterface $dbDriver; protected Repository $repository; protected function setUp(): void { - $this->dbDriver = Factory::getDbInstance(self::URI); - $this->dbDriver->execute('create database if not exists testmicroorm;'); - $this->dbDriver = Factory::getDbInstance(self::URI . '/testmicroorm'); + $this->dbDriver = ConnectionUtil::getConnection('testmicroorm'); // Create table and seed data similar to RepositoryTest $this->dbDriver->execute('create table users ( diff --git a/tests/ConnectionUtil.php b/tests/ConnectionUtil.php new file mode 100644 index 0000000..08847de --- /dev/null +++ b/tests/ConnectionUtil.php @@ -0,0 +1,29 @@ +execute("create database if not exists $database;"); + return Factory::getDbInstance(ConnectionUtil::getUri($database)); + } + + public static function getUri(?string $database = null): Uri + { + $host = getenv('MYSQL_TEST_HOST') ? getenv('MYSQL_TEST_HOST') : '127.0.0.1'; + $uri = new Uri("mysql://root:password@$host"); + + if (empty($database)) { + return $uri; + } + + return $uri->withPath("/$database"); + } +} \ No newline at end of file diff --git a/tests/RepositoryAliasTest.php b/tests/RepositoryAliasTest.php index 86a864c..6b5552c 100644 --- a/tests/RepositoryAliasTest.php +++ b/tests/RepositoryAliasTest.php @@ -3,7 +3,6 @@ namespace Tests; use ByJG\AnyDataset\Db\DbDriverInterface; -use ByJG\AnyDataset\Db\Factory; use ByJG\MicroOrm\FieldMapping; use ByJG\MicroOrm\Mapper; use ByJG\MicroOrm\Query; @@ -13,9 +12,6 @@ class RepositoryAliasTest extends TestCase { - - const URI = 'mysql://root:password@127.0.0.1'; - /** * @var Mapper */ @@ -33,9 +29,7 @@ class RepositoryAliasTest extends TestCase public function setUp(): void { - $this->dbDriver = Factory::getDbInstance(self::URI); - $this->dbDriver->execute('create database if not exists testmicroorm;'); - $this->dbDriver = Factory::getDbInstance(self::URI . "/testmicroorm"); + $this->dbDriver = ConnectionUtil::getConnection("testmicroorm"); $this->dbDriver->execute('create table customers ( id integer primary key auto_increment, diff --git a/tests/RepositoryPkListTest.php b/tests/RepositoryPkListTest.php index 59bbbd9..40f5e11 100644 --- a/tests/RepositoryPkListTest.php +++ b/tests/RepositoryPkListTest.php @@ -3,7 +3,6 @@ namespace Tests; use ByJG\AnyDataset\Db\DbDriverInterface; -use ByJG\AnyDataset\Db\Factory; use ByJG\MicroOrm\Mapper; use ByJG\MicroOrm\Repository; use PHPUnit\Framework\TestCase; @@ -11,9 +10,6 @@ class RepositoryPkListTest extends TestCase { - - const URI = 'mysql://root:password@127.0.0.1'; - /** * @var Mapper */ @@ -31,9 +27,7 @@ class RepositoryPkListTest extends TestCase public function setUp(): void { - $this->dbDriver = Factory::getDbInstance(self::URI); - $this->dbDriver->execute('create database if not exists testmicroorm;'); - $this->dbDriver = Factory::getDbInstance(self::URI . "/testmicroorm"); + $this->dbDriver = ConnectionUtil::getConnection("testmicroorm"); $this->dbDriver->execute('CREATE TABLE items ( storeid INTEGER, diff --git a/tests/RepositoryTest.php b/tests/RepositoryTest.php index 1939beb..3fb912c 100644 --- a/tests/RepositoryTest.php +++ b/tests/RepositoryTest.php @@ -5,7 +5,6 @@ use ByJG\AnyDataset\Core\Enum\Relation; use ByJG\AnyDataset\Core\IteratorFilter; use ByJG\AnyDataset\Db\DbDriverInterface; -use ByJG\AnyDataset\Db\Factory; use ByJG\Cache\Psr16\ArrayCacheEngine; use ByJG\MicroOrm\CacheQueryResult; use ByJG\MicroOrm\DeleteQuery; @@ -44,9 +43,6 @@ class RepositoryTest extends TestCase { - - const URI = 'mysql://root:password@127.0.0.1'; - /** * @var Mapper */ @@ -69,9 +65,7 @@ class RepositoryTest extends TestCase public function setUp(): void { - $this->dbDriver = Factory::getDbInstance(self::URI); - $this->dbDriver->execute('create database if not exists testmicroorm;'); - $this->dbDriver = Factory::getDbInstance(self::URI . "/testmicroorm"); + $this->dbDriver = ConnectionUtil::getConnection("testmicroorm"); $this->dbDriver->execute('create table users ( id integer primary key auto_increment, diff --git a/tests/RepositoryUuidTest.php b/tests/RepositoryUuidTest.php index 05eabd9..bf2bce2 100644 --- a/tests/RepositoryUuidTest.php +++ b/tests/RepositoryUuidTest.php @@ -3,7 +3,6 @@ namespace Tests; use ByJG\AnyDataset\Db\DbDriverInterface; -use ByJG\AnyDataset\Db\Factory; use ByJG\MicroOrm\Literal\HexUuidLiteral; use ByJG\MicroOrm\Repository; use PHPUnit\Framework\TestCase; @@ -11,9 +10,6 @@ class RepositoryUuidTest extends TestCase { - - const URI = 'mysql://root:password@127.0.0.1'; - /** * @var DbDriverInterface */ @@ -26,9 +22,7 @@ class RepositoryUuidTest extends TestCase public function setUp(): void { - $this->dbDriver = Factory::getDbInstance(self::URI); - $this->dbDriver->execute('create database if not exists testmicroorm;'); - $this->dbDriver = Factory::getDbInstance(self::URI . "/testmicroorm"); + $this->dbDriver = ConnectionUtil::getConnection("testmicroorm"); $this->dbDriver->execute('create table usersuuid ( id binary(16) primary key, diff --git a/tests/TransactionManagerTest.php b/tests/TransactionManagerTest.php index 1607f58..1ed8323 100644 --- a/tests/TransactionManagerTest.php +++ b/tests/TransactionManagerTest.php @@ -13,7 +13,6 @@ class TransactionManagerTest extends TestCase { - const URI = 'mysql://root:password@127.0.0.1'; /** * @var TransactionManager */ @@ -22,12 +21,6 @@ class TransactionManagerTest extends TestCase public function setUp(): void { $this->object = new TransactionManager(); - - $dbDriver = Factory::getDbInstance(self::URI); - $dbDriver->execute('create database if not exists a;'); - $dbDriver->execute('create database if not exists b;'); - $dbDriver->execute('create database if not exists c;'); - $dbDriver->execute('create database if not exists d;'); } public function tearDown(): void @@ -35,11 +28,11 @@ public function tearDown(): void $this->object->destroy(); $this->object = null; - $dbDriver = Factory::getDbInstance(self::URI . "/a"); + $dbDriver = ConnectionUtil::getConnection("a"); $dbDriver->execute('drop table if exists users;'); $dbDriver->execute('drop table if exists users1;'); - $dbDriver = Factory::getDbInstance(self::URI . "/b"); + $dbDriver = ConnectionUtil::getConnection("b"); $dbDriver->execute('drop table if exists users2;'); } @@ -48,16 +41,16 @@ public function testAddConnectionError() $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage("The connection already exists with a different instance"); - $dbDrive1 = $this->object->addConnection(self::URI . "/a"); - $dbDrive2 = $this->object->addConnection(self::URI . "/b"); - $dbDrive3 = $this->object->addConnection(self::URI . "/a"); - $dbDrive4 = $this->object->addConnection(self::URI . "/b"); + $dbDrive1 = $this->object->addConnection(ConnectionUtil::getUri("a")); + $dbDrive2 = $this->object->addConnection(ConnectionUtil::getUri("b")); + $dbDrive3 = $this->object->addConnection(ConnectionUtil::getUri("a")); + $dbDrive4 = $this->object->addConnection(ConnectionUtil::getUri("b")); } public function testAddConnection() { - $dbDrive1 = $this->object->addConnection(self::URI . "/a"); - $dbDrive2 = $this->object->addConnection(self::URI . "/b"); + $dbDrive1 = $this->object->addConnection(ConnectionUtil::getUri("a")); + $dbDrive2 = $this->object->addConnection(ConnectionUtil::getUri("b")); $this->object->addDbDriver($dbDrive1); $this->object->addDbDriver($dbDrive2); @@ -71,10 +64,10 @@ public function testAddDbDriverError() $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage("The connection already exists with a different instance"); - $dbDrive1 = Factory::getDbInstance(self::URI . "/a"); - $dbDrive2 = Factory::getDbInstance(self::URI . "/b"); - $dbDrive3 = Factory::getDbInstance(self::URI . "/a"); - $dbDrive4 = Factory::getDbInstance(self::URI . "/b"); + $dbDrive1 = Factory::getDbInstance(ConnectionUtil::getUri("a")); + $dbDrive2 = Factory::getDbInstance(ConnectionUtil::getUri("b")); + $dbDrive3 = Factory::getDbInstance(ConnectionUtil::getUri("a")); + $dbDrive4 = Factory::getDbInstance(ConnectionUtil::getUri("b")); $this->object->addDbDriver($dbDrive1); $this->object->addDbDriver($dbDrive2); @@ -84,8 +77,8 @@ public function testAddDbDriverError() public function testAddDbDriver() { - $dbDrive1 = Factory::getDbInstance(self::URI . "/a"); - $dbDrive2 = Factory::getDbInstance(self::URI . "/b"); + $dbDrive1 = ConnectionUtil::getConnection("a"); + $dbDrive2 = ConnectionUtil::getConnection("b"); $this->object->addDbDriver($dbDrive1); $this->object->addDbDriver($dbDrive2); @@ -99,7 +92,7 @@ public function testAddDbDriver() public function testAddRepository() { - $dbDriver = Factory::getDbInstance(self::URI . "/a"); + $dbDriver = ConnectionUtil::getConnection("a"); $dbDriver->execute('create table users ( id integer primary key auto_increment, @@ -121,8 +114,8 @@ public function testAddRepository() public function testBeginTransaction() { - $this->object->addConnection(self::URI . "/c"); - $this->object->addConnection(self::URI . "/d"); + $this->object->addConnection(ConnectionUtil::getUri("c")); + $this->object->addConnection(ConnectionUtil::getUri("d")); $this->object->beginTransaction(); $this->object->commitTransaction(); @@ -135,8 +128,8 @@ public function testBeginTransactionTwice() $this->expectException(TransactionException::class); $this->expectExceptionMessage("Transaction Already Started"); - $this->object->addConnection(self::URI . "/a"); - $this->object->addConnection(self::URI . "/d"); + $this->object->addConnection(ConnectionUtil::getUri("a")); + $this->object->addConnection(ConnectionUtil::getUri("d")); $this->assertEquals(2, $this->object->count()); @@ -149,7 +142,7 @@ public function testRollbackWithNoTransaction() $this->expectException(TransactionException::class); $this->expectExceptionMessage("There is no Active Transaction"); - $this->object->addConnection(self::URI . "/c"); + $this->object->addConnection(ConnectionUtil::getUri("c")); $this->assertEquals(1, $this->object->count()); $this->object->rollbackTransaction(); } @@ -159,15 +152,15 @@ public function testCommitWithNoTransaction() $this->expectException(TransactionException::class); $this->expectExceptionMessage("There is no Active Transaction"); - $this->object->addConnection(self::URI . "/d"); + $this->object->addConnection(ConnectionUtil::getUri("d")); $this->assertEquals(1, $this->object->count()); $this->object->commitTransaction(); } public function testTransaction() { - $dbDrive1 = Factory::getDbInstance(self::URI . "/a"); - $dbDrive2 = Factory::getDbInstance(self::URI . "/b"); + $dbDrive1 = ConnectionUtil::getConnection("a"); + $dbDrive2 = ConnectionUtil::getConnection("b"); $dbDrive1->execute('create table users1 ( id integer primary key auto_increment, From 5871cfab1ba71cf87b5a19af598dd966290a69de Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 4 Sep 2025 14:49:16 -0400 Subject: [PATCH 11/17] Refactor test setup to centralize database connection logic with `ConnectionUtil` and optimize transaction handling in `Repository`. --- .github/workflows/phpunit.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 82778f4..7a8e244 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -34,6 +34,9 @@ jobs: --health-timeout=20s --health-retries=10 + env: + MYSQL_TEST_HOST: mysql + steps: - uses: actions/checkout@v4 - run: composer install From 6908fd324f9fac123ff6d46f9a2664e84cd254fe Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 4 Sep 2025 14:53:47 -0400 Subject: [PATCH 12/17] Refactor test setup to centralize database connection logic with `ConnectionUtil` and optimize transaction handling in `Repository`. --- tests/TransactionManagerTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/TransactionManagerTest.php b/tests/TransactionManagerTest.php index 1ed8323..95895d1 100644 --- a/tests/TransactionManagerTest.php +++ b/tests/TransactionManagerTest.php @@ -21,6 +21,11 @@ class TransactionManagerTest extends TestCase public function setUp(): void { $this->object = new TransactionManager(); + + ConnectionUtil::getConnection("a"); + ConnectionUtil::getConnection("b"); + ConnectionUtil::getConnection("c"); + ConnectionUtil::getConnection("d"); } public function tearDown(): void From df5f895187a7a8ab403098e5e93b2edafc8506d6 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 4 Sep 2025 14:56:42 -0400 Subject: [PATCH 13/17] Refactor test setup to centralize database connection logic with `ConnectionUtil` and optimize transaction handling in `Repository`. --- src/Repository.php | 4 ++-- tests/TransactionManagerTest.php | 24 ++++++++++++------------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Repository.php b/src/Repository.php index 2d4e035..fdb963d 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -232,7 +232,7 @@ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel if ($isSelect && $i === array_key_last($queries)) { // Trailing SELECT: keep it separate with its own params $selectSql = rtrim($sql, "; \t\n\r\0\x0B"); - $selectParams = $params ?? []; + $selectParams = $params; continue; } @@ -256,7 +256,7 @@ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel $params = $newParams; } - $bigParams = array_merge($bigParams, $params ?? []); + $bigParams = array_merge($bigParams, $params); $bigSqlWrites .= rtrim($sql, "; \t\n\r\0\x0B") . ";\n"; } diff --git a/tests/TransactionManagerTest.php b/tests/TransactionManagerTest.php index 95895d1..fc5fa4e 100644 --- a/tests/TransactionManagerTest.php +++ b/tests/TransactionManagerTest.php @@ -46,16 +46,16 @@ public function testAddConnectionError() $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage("The connection already exists with a different instance"); - $dbDrive1 = $this->object->addConnection(ConnectionUtil::getUri("a")); - $dbDrive2 = $this->object->addConnection(ConnectionUtil::getUri("b")); - $dbDrive3 = $this->object->addConnection(ConnectionUtil::getUri("a")); - $dbDrive4 = $this->object->addConnection(ConnectionUtil::getUri("b")); + $dbDrive1 = $this->object->addConnection(ConnectionUtil::getUri("a")->__toString()); + $dbDrive2 = $this->object->addConnection(ConnectionUtil::getUri("b")->__toString()); + $dbDrive3 = $this->object->addConnection(ConnectionUtil::getUri("a")->__toString()); + $dbDrive4 = $this->object->addConnection(ConnectionUtil::getUri("b")->__toString()); } public function testAddConnection() { - $dbDrive1 = $this->object->addConnection(ConnectionUtil::getUri("a")); - $dbDrive2 = $this->object->addConnection(ConnectionUtil::getUri("b")); + $dbDrive1 = $this->object->addConnection(ConnectionUtil::getUri("a")->__toString()); + $dbDrive2 = $this->object->addConnection(ConnectionUtil::getUri("b")->__toString()); $this->object->addDbDriver($dbDrive1); $this->object->addDbDriver($dbDrive2); @@ -119,8 +119,8 @@ public function testAddRepository() public function testBeginTransaction() { - $this->object->addConnection(ConnectionUtil::getUri("c")); - $this->object->addConnection(ConnectionUtil::getUri("d")); + $this->object->addConnection(ConnectionUtil::getUri("c")->__toString()); + $this->object->addConnection(ConnectionUtil::getUri("d")->__toString()); $this->object->beginTransaction(); $this->object->commitTransaction(); @@ -133,8 +133,8 @@ public function testBeginTransactionTwice() $this->expectException(TransactionException::class); $this->expectExceptionMessage("Transaction Already Started"); - $this->object->addConnection(ConnectionUtil::getUri("a")); - $this->object->addConnection(ConnectionUtil::getUri("d")); + $this->object->addConnection(ConnectionUtil::getUri("a")->__toString()); + $this->object->addConnection(ConnectionUtil::getUri("d")->__toString()); $this->assertEquals(2, $this->object->count()); @@ -147,7 +147,7 @@ public function testRollbackWithNoTransaction() $this->expectException(TransactionException::class); $this->expectExceptionMessage("There is no Active Transaction"); - $this->object->addConnection(ConnectionUtil::getUri("c")); + $this->object->addConnection(ConnectionUtil::getUri("c")->__toString()); $this->assertEquals(1, $this->object->count()); $this->object->rollbackTransaction(); } @@ -157,7 +157,7 @@ public function testCommitWithNoTransaction() $this->expectException(TransactionException::class); $this->expectExceptionMessage("There is no Active Transaction"); - $this->object->addConnection(ConnectionUtil::getUri("d")); + $this->object->addConnection(ConnectionUtil::getUri("d")->__toString()); $this->assertEquals(1, $this->object->count()); $this->object->commitTransaction(); } From dd8d9a980b3a454ec2842746e05d2125ba6a2ec9 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 4 Sep 2025 14:59:41 -0400 Subject: [PATCH 14/17] Refactor test setup to centralize database connection logic with `ConnectionUtil` and optimize transaction handling in `Repository`. --- src/Repository.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Repository.php b/src/Repository.php index fdb963d..19f7791 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -410,12 +410,11 @@ public function getByQueryRaw(QueryBuilderInterface $query): array * @param mixed $instance * @param UpdateConstraint|null $updateConstraint * @return mixed - * @throws Exception\InvalidArgumentException + * @throws InvalidArgumentException * @throws OrmBeforeInvalidException * @throws OrmInvalidFieldsException * @throws RepositoryReadOnlyException * @throws UpdateConstraintException - * @throws \ByJG\Serializer\Exception\InvalidArgumentException */ public function save(mixed $instance, UpdateConstraint $updateConstraint = null): mixed { From c9f3d16b681168a50582635711ef1506cbde5e02 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 4 Sep 2025 20:27:27 -0400 Subject: [PATCH 15/17] Refactor `bulkExecute` to simplify parameter handling, remove unnecessary variable reassignment, and improve code clarity. Add test for bulk insert without parameters. --- src/Repository.php | 14 +++++--------- tests/BulkTest.php | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/Repository.php b/src/Repository.php index 19f7791..6dcdf71 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -211,7 +211,6 @@ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel } $dbDriver = $this->getDbDriverWrite(); - $pdo = $dbDriver->getDbConnection(); $bigSqlWrites = ''; $selectSql = null; @@ -237,26 +236,23 @@ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel } // For write statements, avoid parameter name collisions by uniquifying named params - if (!empty($params)) { - $newParams = []; +// if (!empty($params)) { foreach ($params as $key => $value) { // Only process named parameters (string keys) - if (is_string($key)) { + if (isset($bigParams[$key])) { $uniqueKey = $key . '__b' . $i; // Replace ":key" with ":key__b{i}" using a safe regex that avoids partial matches $pattern = '/(?assertCount(1, $rows); $this->assertEquals('Charlie', $rows[0]->getName()); } + + public function testBulkInsertNoParams(): void + { + // initial rows are 3; next autoincrement id should be 4 after insert + $insert = QueryRaw::getInstance("insert into users (name, createdate) values ('Charlie', '2025-01-01')"); + + // Outer query selects last_insert_rowid() from the single-row subquery + $selectLastId = QueryRaw::getInstance($this->repository->getDbDriver()->getDbHelper()->getSqlLastInsertId()); + + $it = $this->repository->bulkExecute([$insert, $selectLastId], null); + $result = $it->toArray(); + + $this->assertCount(1, $result); + // SQLite returns integer for last_insert_rowid(); expect 4 here + $this->assertEquals(4, (int)$result[0]['id']); + + // Also ensure the inserted row exists + $rows = $this->repository->getByFilter('name = :name', ['name' => 'Charlie']); + $this->assertCount(1, $rows); + $this->assertEquals('Charlie', $rows[0]->getName()); + } + } From b8e8666e165b1b40d7e8c7af0a5a730863ae1a42 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Fri, 5 Sep 2025 11:39:02 -0400 Subject: [PATCH 16/17] Refactor `bulkExecute` to simplify parameter handling, remove unnecessary variable reassignment, and improve code clarity. Add test for bulk insert without parameters. --- docs/updating-the-database.md | 41 ++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/docs/updating-the-database.md b/docs/updating-the-database.md index b143c30..dd9730d 100644 --- a/docs/updating-the-database.md +++ b/docs/updating-the-database.md @@ -148,21 +148,28 @@ applied. Signature: ```php -public function Repository::bulkExecute(array $queries, ?\ByJG\AnyDataset\Db\IsolationLevelEnum $isolationLevel = null): void +public function Repository::bulkExecute( + array $queries, + ?\ByJG\AnyDataset\Db\IsolationLevelEnum $isolationLevel = null +): ?\ByJG\AnyDataset\Core\GenericIterator ``` Rules and behavior: - Accepts an array of QueryBuilderInterface or Updatable instances (e.g., InsertQuery, UpdateQuery, DeleteQuery). Each - item is built and executed with the repository write driver. + item is built with the repository write driver. Non-SELECT statements are batched and executed together; a trailing + SELECT (if present) is executed separately. - All queries are executed inside a transaction. If any query throws an exception, the transaction is rolled back and the exception is rethrown. +- If the last command is a SELECT, bulkExecute returns an iterator (GenericIterator) with its results. Intermediate + SELECT statements are allowed but do not return iterators; their results are not yielded. +- If there is no trailing SELECT, bulkExecute returns an empty iterator. - Passing an empty array throws InvalidArgumentException. - Passing an item that is not a QueryBuilderInterface or Updatable throws InvalidArgumentException. - You can optionally pass a transaction isolation level using IsolationLevelEnum. The transaction allows joining an existing transaction if present. -Example: +Example (writes only): ```php table('users') ->where('name = :name', ['name' => 'OldName']); -$repository->bulkExecute([$insert, $update, $delete]); +$it = $repository->bulkExecute([$insert, $update, $delete]); +// $it is an empty iterator because there is no trailing SELECT +``` + +Example (returning results when the last command is a SELECT): + +```php + 'Charlie', + 'createdate' => '2025-01-01' +]); + +// Example of a SELECT as the last command (driver-specific last insert id) +$selectLastId = QueryRaw::getInstance($repository->getDbDriver()->getDbHelper()->getSqlLastInsertId()); + +$it = $repository->bulkExecute([$insert, $selectLastId]); +foreach ($it as $row) { + // process results (e.g., $row['id']) +} ``` Notes: -- Parameter names can overlap between queries (e.g., multiple queries using :name) because each query is built and - executed independently. +- Parameter names can overlap between queries (e.g., multiple queries using :name). bulkExecute internally batches + non-SELECT statements and safely renames parameters to avoid collisions; the trailing SELECT (if present) is executed + separately. - If you need a specific transaction isolation level, pass it as the second argument, e.g., `IsolationLevelEnum::SERIALIZABLE`. \ No newline at end of file From 85cffc5b45fd8cb9284569deb296ec004f27189c Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Fri, 5 Sep 2025 14:53:09 -0400 Subject: [PATCH 17/17] Minor changes --- src/Repository.php | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/src/Repository.php b/src/Repository.php index 6dcdf71..5f85f5f 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -236,22 +236,20 @@ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel } // For write statements, avoid parameter name collisions by uniquifying named params -// if (!empty($params)) { - foreach ($params as $key => $value) { - // Only process named parameters (string keys) - if (isset($bigParams[$key])) { - $uniqueKey = $key . '__b' . $i; - // Replace ":key" with ":key__b{i}" using a safe regex that avoids partial matches - $pattern = '/(? $value) { + // Only process named parameters (string keys) + if (isset($bigParams[$key])) { + $uniqueKey = $key . '__b' . $i; + // Replace ":key" with ":key__b{i}" using a safe regex that avoids partial matches + $pattern = '/(?