Docs

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