mini\Database
namespace
Database
Mini's database layer wraps a database engine (PDO by default) with an immutable query builder.
Quick Start
// Raw queries - returns iterable
foreach (db()->query("SELECT * FROM users WHERE active = ?", [1]) as $row) {
echo $row['name'];
}
// Convenience methods
$user = db()->queryOne("SELECT * FROM users WHERE id = ?", [123]);
$count = db()->queryField("SELECT COUNT(*) FROM users");
$ids = db()->queryColumn("SELECT id FROM users WHERE role = ?", ['admin']);
// Mutations
db()->exec("INSERT INTO users (name, email) VALUES (?, ?)", ['John', 'john@example.com']);
db()->exec("UPDATE users SET active = ? WHERE id = ?", [0, 123]);
db()->exec("DELETE FROM users WHERE id = ?", [123]);
// Last insert ID
$userId = db()->lastInsertId();
// Transactions (auto rollback on exception, nested transactions throw)
db()->transaction(function() {
db()->exec("INSERT INTO users ...");
db()->exec("INSERT INTO activity_log ...");
});
PartialQuery: Composable Queries
PartialQuery is an immutable query builder. Each method returns a NEW instance, making queries safe to reuse and compose.
// Basic usage - iterate directly
foreach (db()->partialQuery('users')->eq('active', 1) as $user) {
echo $user['name'];
}
// Composition - original unchanged
$active = db()->partialQuery('users')->eq('active', 1);
$admins = $active->eq('role', 'admin'); // New instance
$mods = $active->eq('role', 'moderator'); // New instance
// Methods - all conditions are combined with AND
$query->eq('column', $value) // = (NULL becomes IS NULL)
$query->lt('column', $value) // <
$query->lte('column', $value) // <=
$query->gt('column', $value) // >
$query->gte('column', $value) // >=
$query->in('column', [...]) // IN (...)
$query->in('column', $subquery) // IN (SELECT ...) via CTE
$query->where('sql', $params) // Raw WHERE clause (ANDed with existing)
$query->order('created_at DESC') // ORDER BY (replaces previous)
$query->limit(100) // LIMIT (see "Default Limit" section)
$query->offset(50) // OFFSET
// Execution
foreach ($query as $row) { } // Iterate
$row = $query->one(); // First row or null
$total = $query->count(); // COUNT(*) ignoring LIMIT
$ids = $query->column(); // First column as array
Model Pattern
Define query methods on your model class. This is explicit, type-safe, and doesn't rely on magic methods.
class User
{
public int $id;
public string $name;
public string $email;
public bool $active;
public ?string $deleted_at;
/**
* Base query with hydration
* @return PartialQuery<User>
*/
public static function query(): PartialQuery
{
return db()->partialQuery('users')->withEntityClass(self::class, false);
}
/** @return PartialQuery<User> */
public static function active(): PartialQuery
{
return self::query()->eq('active', 1)->eq('deleted_at', null);
}
/** @return PartialQuery<User> */
public static function admins(): PartialQuery
{
return self::active()->eq('role', 'admin');
}
public static function find(int $id): ?User
{
return self::query()->eq('id', $id)->one();
}
}
// Usage
foreach (User::admins()->order('name')->limit(10) as $user) {
echo $user->name; // IDE autocomplete works
}
$user = User::find(123);
$count = User::active()->count();
Hydration with withEntityClass()
The second parameter controls instantiation:
->withEntityClass(User::class, false) // Skip constructor (default)
->withEntityClass(User::class, []) // Call constructor with no args
->withEntityClass(User::class, [$arg]) // Call constructor with args
Automatic Type Conversion
Hydration automatically converts database values to PHP types using the converter registry. Built-in conversions:
class Post
{
public int $id; // INTEGER → int (PDO native)
public string $title; // TEXT → string (PDO native)
public ?string $summary; // NULL preserved
public bool $published; // 0/1/"0"/"1"/"" → bool
public \DateTimeImmutable $created_at; // See datetime formats below
public \DateTime $updated_at; // See datetime formats below
public Status $status; // TEXT/INTEGER → BackedEnum (auto)
}
enum Status: string {
case Draft = 'draft';
case Published = 'published';
}
DateTime conversion supports multiple formats:
- String:
"2024-01-15 10:30:00"- interpreted insqlTimezone - Integer (seconds):
1705315800- Unix timestamp (always UTC) - Integer (milliseconds):
1705315800123- auto-detected when >= 100 billion - Float:
1705315800.123456- seconds with microsecond precision
Timezone behavior: String dates from the database are interpreted in Mini::$mini->sqlTimezone (defaults to '+00:00' UTC) and automatically converted to the application timezone. Configure via SQL_TIMEZONE or MINI_SQL_TIMEZONE environment variable using offset format (e.g., '+00:00', '-05:00').
For SQL Server (which cannot set session timezone), Mini verifies the server's timezone matches sqlTimezone and throws if it doesn't.
Custom Row Hydration with Hydration Interface
For complex hydration/dehydration (computed properties, column renaming, nested objects), implement Hydration:
use mini\Database\Hydration;
class User implements Hydration
{
public int $id;
public string $fullName;
public \DateTimeImmutable $createdAt;
public static function fromSqlRow(array $row): static
{
$user = new static();
$user->id = $row['id'];
$user->fullName = $row['first_name'] . ' ' . $row['last_name'];
$user->createdAt = new \DateTimeImmutable($row['created_at']);
return $user;
}
public function toSqlRow(): array
{
$parts = explode(' ', $this->fullName, 2);
return [
'id' => $this->id,
'first_name' => $parts[0],
'last_name' => $parts[1] ?? '',
'created_at' => $this->createdAt->format('Y-m-d H:i:s'),
];
}
}
// Hydration uses fromSqlRow() automatically when reading
$users = User::query()->limit(10);
// Dehydration uses toSqlRow() automatically when writing
$user->save();
Custom Value Objects with SqlValueHydrator
For value objects that map to a single column, implement SqlValueHydrator:
use mini\Database\SqlValue;
use mini\Database\SqlValueHydrator;
class Money implements SqlValue, SqlValueHydrator
{
public function __construct(public readonly int $cents) {}
// SQL column → PHP (hydration)
public static function fromSqlValue(string|int|float|bool $value): static
{
return new static((int) $value);
}
// PHP → SQL column (queries)
public function toSqlValue(): int
{
return $this->cents;
}
}
// Now works automatically in entities
class Order {
public int $id;
public Money $total; // Hydrated from INTEGER column
}
Custom Converters
For types you don't control, register a converter:
// In bootstrap.php
$registry = Mini::$mini->get(ConverterRegistryInterface::class);
// sql-value → SomeLibraryType
$registry->register(
fn(string $v): SomeType => SomeType::parse($v),
null, // target: infer from return type
'sql-value' // source: database values
);
For types without registered converters, raw PDO values are assigned directly.
Relationships
Define relationship methods explicitly. No magic, no autowiring - just clear, predictable code you write once.
class User
{
public int $id;
public string $name;
public static function query(): PartialQuery
{
return db()->partialQuery('users')->withEntityClass(self::class, false);
}
/**
* Posts by this user
* @return PartialQuery<Post>
*/
public function posts(): PartialQuery
{
return Post::query()->eq('user_id', $this->id);
}
/**
* Published posts only
* @return PartialQuery<Post>
*/
public function publishedPosts(): PartialQuery
{
return $this->posts()->where('published_at IS NOT NULL');
}
/**
* Friends (many-to-many via friendships table)
* @return PartialQuery<User>
*/
public function friends(): PartialQuery
{
return db()->partialQuery('users', '
SELECT u.* FROM users u
INNER JOIN friendships f ON (
(f.friend_id = u.id AND f.user_id = ?)
OR (f.user_id = u.id AND f.friend_id = ?)
)
', [$this->id, $this->id])
->withEntityClass(self::class, false);
}
}
class Post
{
public int $id;
public int $user_id;
public string $title;
public ?string $published_at;
public static function query(): PartialQuery
{
return db()->partialQuery('posts')->withEntityClass(self::class, false);
}
/** Get the author */
public function author(): ?User
{
return User::find($this->user_id);
}
/** @return PartialQuery<Comment> */
public function comments(): PartialQuery
{
return Comment::query()->eq('post_id', $this->id);
}
/** @return PartialQuery<Post> */
public static function published(): PartialQuery
{
return self::query()->where('published_at IS NOT NULL');
}
}
// Usage
$user = User::find(1);
foreach ($user->posts()->order('created_at DESC')->limit(10) as $post) {
echo $post->title;
}
foreach ($user->friends()->eq('active', 1) as $friend) {
echo $friend->name;
}
$post = Post::published()->order('published_at DESC')->one();
$author = $post?->author();
$commentCount = $post?->comments()->count();
Why explicit methods instead of magic?
- Type safety - IDE knows return types, autocomplete works
- No surprises - No
__get/__callmagic that may be deprecated - Discoverable - Methods appear in IDE, easy to find and understand
- Customizable - Add filtering, ordering, or joins as needed
- Write once - You define each relationship once, use it everywhere
Row-Level Access Control
Define scoped query methods that embed authorization rules. The WHERE clause is the authorization - no separate permission checks needed.
class User
{
public static function query(): PartialQuery
{
return db()->partialQuery('users')->withEntityClass(self::class, false);
}
/** Users the current user can read */
public static function readable(): PartialQuery
{
return self::query()->eq('organization_id', Auth::orgId());
}
/** Users the current user can update */
public static function updatable(): PartialQuery
{
return self::readable()->where('role != ?', ['admin']); // Can't edit admins
}
/** Users the current user can delete */
public static function deletable(): PartialQuery
{
return self::updatable()->where('id != ?', [Auth::userId()]); // Can't delete self
}
public static function find(int $id): ?User
{
return self::readable()->eq('id', $id)->one();
}
}
// Read - returns null if not authorized
$user = User::find(123);
// Update - returns 0 rows affected if not authorized
db()->update(User::updatable()->eq('id', 123), ['name' => 'Frode']);
// Delete - returns 0 rows affected if not authorized
db()->delete(User::deletable()->eq('id', 456));
Authorization failures are silent (0 rows affected) rather than throwing exceptions. This makes the pattern simple to use and test.
Simple ::mine() Pattern
For simpler cases, use ::mine() as a single security boundary:
class Post
{
public static function query(): PartialQuery
{
return db()->partialQuery('posts')->withEntityClass(self::class, false);
}
/** Posts accessible to current user */
public static function mine(): PartialQuery
{
return self::query()->where('user_id = ? OR visibility = ?', [Auth::userId(), 'public']);
}
public static function find(int $id): ?Post
{
return self::mine()->eq('id', $id)->one();
}
}
// User-facing queries use ::mine()
$posts = Post::mine()->order('created_at DESC')->limit(20);
$post = Post::find(123); // Returns null if not authorized
// Admin/internal queries bypass with ::query()
$allPosts = Post::query()->eq('status', 'spam'); // For moderation
DELETE and UPDATE
// Delete with composable scopes
db()->delete(User::query()->eq('status', 'spam'));
// Update with array
db()->update(
User::query()->eq('status', 'inactive'),
['status' => 'archived', 'archived_at' => date('Y-m-d H:i:s')]
);
// Update with raw SQL
db()->update(
User::query()->eq('status', 'active'),
'login_count = login_count + 1'
);
Subqueries work with DELETE and UPDATE:
// Delete users who have no posts
$usersWithPosts = Post::query()->select('user_id');
db()->delete(User::query()->in('id', $usersWithPosts));
// Update users who ordered a specific product
$buyers = Order::query()->eq('product_id', 123)->select('user_id');
db()->update(User::query()->in('id', $buyers), ['vip' => 1]);
Note: Subqueries in in() require an explicit ->select('column') to specify which column to match.
Default Limit: 1000 Rows
Mini applies a default limit of 1000 rows to bulk fetch methods. This is a deliberate safety measure:
- Prevents accidental full-table scans - Forgetting a LIMIT won't fetch millions of rows
- Encourages pagination as a first-class concern - Developers should design for pagination from the start, not as an afterthought
- Follows BigTable/NoSQL best practices - Use indexed cursors for efficient paging
// These apply default 1000-row limit:
foreach ($query as $row) { } // Stops at 1000
$query->toArray(); // Max 1000 elements
$query->column(); // Max 1000 values
// These do NOT apply default limit:
$query->field(); // Single scalar value
$query->one(); // Single row
$query->count(); // Aggregate, no row data
$query->in('col', $subquery) // Subqueries bypass limit automatically
// Override when needed:
$query->limit(5000); // Explicit limit
$query->limit(PHP_INT_MAX); // Fetch all rows (bypass default)
For pagination, use index-based cursors rather than OFFSET:
// Good: cursor-based (efficient at any page)
$posts = Post::query()
->gt('id', $lastSeenId)
->order('id')
->limit(50);
// Avoid: offset-based (slow on deep pages)
$posts = Post::query()
->order('id')
->offset(10000)
->limit(50);
The limit applies only to the root query execution - subqueries used with in() are not limited since they're part of a larger query.
Configuration
By default, Mini uses SQLite at _database.sqlite3. For other databases:
// _config/PDO.php
return new PDO(
'mysql:host=localhost;dbname=myapp;charset=utf8mb4',
'username',
'password'
);
Mini auto-configures charset, timezone, error mode, and fetch mode.
Direct PDO Access
$pdo = db()->getPdo();
$stmt = $pdo->prepare("...");