Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
100017b
feat: extend build methods to accept DbDriverInterface and add MySQL …
HilarioJrx Aug 14, 2025
3472fa2
Merge pull request #29 from HilarioJrx/5.0
byjg Aug 14, 2025
6a8b4ca
Add `bulkExecute` method to Repository with transaction support
byjg Sep 3, 2025
aad9102
Refactor `save` method in `Repository` for improved readability and m…
byjg Sep 3, 2025
9c9b303
Add support for returning results from `bulkExecute` and introduce `Q…
byjg Sep 3, 2025
ba86f58
Fix Psalm
byjg Sep 3, 2025
f8cbf36
Merge branch 'master' into 5.0
byjg Sep 3, 2025
447733f
Optimize `bulkExecute` in `Repository` to support combined query exec…
byjg Sep 4, 2025
1ed0183
Enhance `bulkExecute` to inline parameters and support multi-statemen…
byjg Sep 4, 2025
617e00d
Enhance `bulkExecute` to inline parameters and support multi-statemen…
byjg Sep 4, 2025
213967c
Switch database configuration and tests from SQLite to MySQL, introdu…
byjg Sep 4, 2025
7c14ddc
Refactor test setup to centralize database connection logic with `Con…
byjg Sep 4, 2025
5871cfa
Refactor test setup to centralize database connection logic with `Con…
byjg Sep 4, 2025
6908fd3
Refactor test setup to centralize database connection logic with `Con…
byjg Sep 4, 2025
df5f895
Refactor test setup to centralize database connection logic with `Con…
byjg Sep 4, 2025
dd8d9a9
Refactor test setup to centralize database connection logic with `Con…
byjg Sep 4, 2025
c9f3d16
Refactor `bulkExecute` to simplify parameter handling, remove unneces…
byjg Sep 5, 2025
b8e8666
Refactor `bulkExecute` to simplify parameter handling, remove unneces…
byjg Sep 5, 2025
85cffc5
Minor changes
byjg Sep 5, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/phpunit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,23 @@ 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

env:
MYSQL_TEST_HOST: mysql

steps:
- uses: actions/checkout@v4
- run: composer install
Expand Down
10 changes: 10 additions & 0 deletions .idea/runConfigurations/MySQL_Server_Test.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .idea/runConfigurations/PHPUnit.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
83 changes: 83 additions & 0 deletions docs/query-raw.md
Original file line number Diff line number Diff line change
@@ -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<array<string, mixed>>
```

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.
92 changes: 91 additions & 1 deletion docs/updating-the-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,94 @@ 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']);
```
```

## 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
): ?\ByJG\AnyDataset\Core\GenericIterator
```

Rules and behavior:

- Accepts an array of QueryBuilderInterface or Updatable instances (e.g., InsertQuery, UpdateQuery, DeleteQuery). Each
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 (writes only):

```php
<?php
use ByJG\MicroOrm\Repository;
use ByJG\MicroOrm\InsertQuery;
use ByJG\MicroOrm\UpdateQuery;
use ByJG\MicroOrm\DeleteQuery;
use ByJG\AnyDataset\Db\Factory;

$db = Factory::getDbInstance('sqlite:///tmp/example.db');
$repository = new Repository($db, MyModel::class);

$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' => 'OldName']);

$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
<?php
use ByJG\MicroOrm\QueryRaw;

$insert = InsertQuery::getInstance('users', [
'name' => '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). 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`.
3 changes: 2 additions & 1 deletion src/DeleteQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)) {
Expand Down
19 changes: 12 additions & 7 deletions src/InsertBulkQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -96,16 +101,16 @@ 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
}
}
$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
Expand Down
17 changes: 11 additions & 6 deletions src/InsertQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 '
Expand Down
Loading