Query
A fluent SQL builder + micro-ORM layer that runs directly on PDO.
It bundles three small but mighty classes:
Piece | Purpose |
---|---|
QueryBuilder | Chainable, injection-safe SQL for 90 % of queries |
EntityRepository | Pragmatic base repo with find(), save(), delete(), … |
RepositoryFactory | Auto-wires repositories so you never register them by hand |
1 · Installation
composer require monkeyscloud/monkeyslegion-query
Only PDO is required at runtime—no heavyweight ORM dependencies.
2 · Quick Tour
use MonkeysLegion\Query\QueryBuilder;
/** @var PDO $pdo (injected from the DI container) */
$qb = new QueryBuilder($pdo);
// 🔎 SELECT + JOIN + paging
$rows = $qb->select(['u.id','u.name','p.title'])
->from('users','u')
->join('posts','p','p.user_id','=','u.id')
->where('u.status','=','active')
->orderBy('u.created_at','DESC')
->limit(10)->offset(20)
->fetchAll(App\Entity\User::class);
// ➕ INSERT
$newId = $qb->insert('users', [
'name' => 'Alice',
'email' => 'alice@example.com',
]);
// ✏️ UPDATE
$qb->update('users',['name'=>'Alice Smith'])
->where('id','=',$newId)
->execute();
// ❌ DELETE
$qb->delete('users')
->where('id','=',$newId)
->execute();
QueryBuilder Cheat-Sheet
| Chain piece | Result |
|-------------|--------|
| `select(cols)` → `from(table, alias?)` | Begin a `SELECT` |
| `join()` / `leftJoin()` | Join tables with ON helpers |
| `where()` / `orWhere()` | Add predicates—values are bound |
| `groupBy()` / `having()` | Aggregations |
| `orderBy()` / `limit()` / `offset()` | Paging |
| `insert(table, data[])` | Returns last-insert-id |
| `update(table, data[]) → execute()` | Affected rows |
| `delete(table) → execute()` | Affected rows |
| `fetchAll()` / `fetchOne()` | Hydrates as arrays or objects |
All parameters are shipped as named binds—no string concatenation, no SQL injection.
Repositories in 30 Seconds
namespace App\Repository;
use MonkeysLegion\Repository\EntityRepository;
final class UserRepository extends EntityRepository
{
protected string $table = 'users';
protected string $entityClass = App\Entity\User::class;
}
// Usage
/** @var UserRepository $users */
$users = $container->get(UserRepository::class);
$user = $users->find(42);
$list = $users->findAll(['status' => 'new']);
$count = $users->count();
$users->save($user);
$users->delete($user->id);
RepositoryFactory — Zero Boilerplate
use MonkeysLegion\Repository\RepositoryFactory;
/** @var RepositoryFactory $factory (injected) */
$posts = $factory->create(App\Repository\PostRepository::class);
The factory checks that the class extends EntityRepository, injects the shared QueryBuilder, and hands you a ready-to-use repo.
Minimal DI wiring:
return [
PDO::class => fn () => new PDO('sqlite:' . base_path('db.sqlite')),
QueryBuilder::class => fn ($c) => new QueryBuilder($c->get(PDO::class)),
RepositoryFactory::class=> fn ($c) => new RepositoryFactory($c->get(QueryBuilder::class)),
];
6 · Advanced Usage
Below are the three power-user features baked into QueryBuilder for situations that fall outside everyday CRUD. Each is 100 % PDO-driven, so nothing here adds extra dependencies.
6.1 · Running hand-rolled SQL (custom())
/** @var QueryBuilder $qb */
$affected = $qb->custom(
'UPDATE posts SET views = views + 1 WHERE id = :id',
['id' => $postId],
);
// $affected === number of rows touched
Accepts any SQL statement—SELECT, INSERT, DDL, even PRAGMA ….
Binds every :param in the second argument; anything missing throws immediately.
Returns either the PDOStatement result (SELECT) or the row-count (INSERT/UPDATE/DELETE).
Use this sparingly—most DML can still be expressed fluently—but it’s there when you need UPSERT, vendor-specific syntax, or window functions.
6.2 · ACID-safe batches (transactions)
$pdo->beginTransaction();
try {
$orders->save($order); // repository call #1
$invoices->save($invoice); // repository call #2
$pdo->commit(); // ⬆️ all OK → commit once
} catch (\Throwable $e) {
$pdo->rollBack(); // ⬅️ anything failed → revert
throw $e; // bubble up for logging/HTTP 500
}
Because repositories ultimately call QueryBuilder, they share the same PDO handle—so any save()/delete() made inside the block is covered by the transaction.
6.3 · Macros: extending the DSL
If you need the same clause in multiple queries, register a macro once and reuse it everywhere.
The builder exposes a static macro() when you pull in the optional Macroable trait:
use MonkeysLegion\Query\QueryBuilder;
use MonkeysLegion\Query\Concerns\Macroable;
QueryBuilder::macro('isActive', function () {
/** @var QueryBuilder $this */
return $this->where('status', '=', 'active');
});
Now every builder inherits the new verb:
$activeUsers = $qb->select('*')
->from('users')
->isActive() // 👈 macro
->fetchAll(App\Entity\User::class);
Macros are chainable—return $this or a new builder instance. Think of them as query scopes: ->published(), ->forYear(2025), ->visibleTo($user) … whatever your domain needs.
License
MIT © 2025 MonkeysCloud