diff --git a/README.md b/README.md index af91402..62c94d0 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,61 @@ # WebFiori Documentation -Documentation repository for WebFiori framework. Any change to this repo in the `main` branch is reflected directly to the website at https://webfiori.com/docs. The documentation in this repo covers version 3.x.x of the framework. +Documentation repository for WebFiori framework. Any change to this repo in the `main` branch is reflected directly to the website at https://webfiori.com/docs. + +This repo currently covers **version 3.x** of the framework. ## Quick Start - **New to WebFiori?** Start with [Introduction](introduction.md) - **Ready to install?** Check [Installation Guide](installation.md) -- **Browse all topics:** See [Learning Center](https://webfiori.com/docs) +- **Browse all topics:** See [Index](index.md) ## Documentation Structure -- **Getting Started**: Introduction, Installation, Folder Structure -- **Core Features**: Routing, Response handling, Web Services -- **UI Development**: Web Pages, UI Package, Themes -- **Data Management**: Database, Sessions, JSON handling -- **Advanced Features**: Middleware, Background Tasks, Email +| Section | Topics | +|---------|--------| +| Getting Started | Introduction, Installation, Folder Structure, Basic Usage | +| Core Features | Routing, Response, Web Services, Middleware | +| UI Development | Web Pages, UI Package, Themes, i18n | +| Data & Storage | Database, Repository Pattern, Migrations, Sessions, Caching, JSON | +| File Handling | Uploading Files, Streaming Uploads, Resumable Uploads | +| Advanced | Background Tasks, Job Queue, Email, Event Dispatcher, DI, Security | +| Infrastructure | CLI, Health Checks, Logging, Environment Variables | + +## Libraries Covered + +| Library | Docs | +|---------|------| +| `webfiori/framework` | Routing, Middleware, Sessions, CLI commands | +| `webfiori/database` | Database, Repository, Migrations | +| `webfiori/http` | Web Services, MVC | +| `webfiori/file` | File Uploads (standard, streaming, resumable) | +| `webfiori/cli` | Command Line Interface | +| `webfiori/jsonx` | JSON handling | +| `webfiori/ui` | UI Package, Themes, Web Pages | +| `webfiori/mail` | Sending Emails | + +## Versioning + +See [VERSIONING.md](VERSIONING.md) for the branching and versioning strategy. + +Summary: `main` is always the current version. Old versions are frozen into branches (e.g., `v3`) only when work on the next major begins. ## Contributing -Found an error or want to improve the documentation? 1. Fork this repository -2. Make your changes -3. Submit a pull request +2. Create a branch from `dev` +3. Make your changes +4. Submit a pull request to `dev` + +Guidelines: +- Use `> **Since X.Y**` notes for features added in minor releases +- Use `> **Deprecated since X.Y.**` for deprecated features +- Keep code examples minimal and runnable +- Verify class/method names against the actual library source ## Links - **Framework**: [WebFiori on GitHub](https://github.com/WebFiori/framework) - **Website**: [webfiori.com](https://webfiori.com) +- **API Docs**: [webfiori.com/docs](https://webfiori.com/docs) diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 0000000..bb6f6ef --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,102 @@ +# Documentation Versioning Strategy + +This document describes how documentation versions are managed in this repository. + +## Branch Model + +| Branch | Purpose | +|--------|---------| +| `main` | Always the **current** version's docs. All active work happens here. | +| `v3`, `v4`, etc. | Frozen snapshots created when the **next** major version's docs begin. | + +## Rules + +1. **`main` is always current.** Today it's v3. When v4 work starts, it becomes v4. +2. **Branch only when the next major starts.** Before making breaking v4 changes on `main`, branch `v3` off to freeze it. +3. **Never branch for minor releases.** v3.1, v3.2 docs all go on `main`. Use `> **Since X.Y**` notes. +4. **Old branches are mostly frozen.** Only typo fixes and critical corrections. +5. **Cherry-pick shared fixes.** If a fix applies to both old and current, fix on the old branch and cherry-pick to `main`. + +## Timeline + +``` +v3.0 ships v3.1 ships start v4 work v4.0 ships start v5 work + │ │ │ │ │ + │ │ branch v3! │ branch v4! + │ │ │ │ │ +main: ──v3 docs────────────────────│──v4 docs───────────────────────│──v5 docs──→ + │ │ +v3: ●───hotfixes only───────→ │ + │ +v4: ●───hotfixes──→ +``` + +## Scenarios + +### New feature in a minor release (e.g., v3.1) + +Commit to `main`. Add a version note: + +```markdown +## Connection Pooling + +> **Since 3.1** + +The library includes a built-in connection pool... +``` + +### Starting work on next major (e.g., v4) + +```bash +git checkout main +git checkout -b v3 # Freeze v3 docs +git checkout main # main is now v4 docs +``` + +### Fix a doc bug that affects both v3 and v4 + +```bash +git checkout v3 +# apply fix +git commit -m "fix: typo in database.md" + +git checkout main +git cherry-pick +``` + +### Fix something only relevant to an old version + +```bash +git checkout v3 +# fix content that doesn't exist in main +git commit -m "fix: clarify deprecated syntax in v3" +# No cherry-pick needed +``` + +### Deprecating a feature + +```markdown +> **Deprecated since 3.2.** Use `StreamingUploader` instead. Will be removed in v4. +``` + +When v4 docs begin on `main`, delete the deprecated content entirely. It remains in the `v3` branch. + +### End of life for an old version + +Keep the branch for archival (costs nothing) or delete it. Remove the old version's route from the website if you no longer want to serve it. + +## Version Markers + +| Situation | Marker | +|-----------|--------| +| Feature added in minor | `> **Since 3.1**` | +| Feature deprecated | `> **Deprecated since 3.2.** Use X instead.` | +| Feature removed in major | Delete from `main`; stays in old branch | +| Behavior changed in major | Update on `main`; old text stays in old branch | + +## Website Routing + +| URL | Source | +|-----|--------| +| `webfiori.com/docs` | `main` branch (current version) | +| `webfiori.com/docs/v3` | `v3` branch (when v4 is current) | diff --git a/built-in-middleware.md b/built-in-middleware.md index 54b98d9..6625e4c 100644 --- a/built-in-middleware.md +++ b/built-in-middleware.md @@ -116,6 +116,33 @@ new HttpCacheMiddleware([ Best for read-heavy, infrequently-changing endpoints (product catalogs, static configs). +## Response Caching + +Full server-side response caching. Stores the complete response (headers, status code, body) and serves it directly on subsequent requests, bypassing all application logic. + +```php +use WebFiori\Framework\Middleware\CacheMiddleware; +``` + +Registered name: `cache`. Belongs to the `web` group. Priority: 50. + +This middleware works with the route's `cache-duration` option: + +```php +Router::page([ + RouteOption::PATH => '/products', + RouteOption::TO => ProductsPage::class, + RouteOption::MIDDLEWARE => ['cache'], + RouteOption::CACHE_DURATION => 300 // Cache for 5 minutes +]); +``` + +How it works: +1. `before()`: Checks if a cached response exists for the current URI. If found, sends it immediately (no further processing). +2. `after()`: If the response wasn't cached, stores the full response (body, headers, status code) with the configured TTL. + +Cache storage uses `FileStorage` by default (stored in the system temp directory). The cache key is derived from the request URI. + ## Session Start Starts the session. Required by middleware that reads session data (CSRF, auth, rate limiter with session keys). diff --git a/command-line-interface.md b/command-line-interface.md index 132bacd..999d8a8 100644 --- a/command-line-interface.md +++ b/command-line-interface.md @@ -16,6 +16,12 @@ In this page: * [Confirm](#confirm) * [Multiple Choice](#multiple-choice) * [Coloring Output](#coloring-output) +* [Verbosity Levels](#verbosity-levels) +* [Progress Bars](#progress-bars) +* [Table Display](#table-display) +* [Signal Handling](#signal-handling) +* [PHP 8 Attributes](#php-8-attributes) +* [Testing Commands](#testing-commands) ## Introduction One of the features of the framework is the ability to run it as a command line application using terminal. This can be useful if the server that the application is deployed in have SSH access. The command line interface of the framework has a limit functionality but the developer can extend it by creating custom commands. @@ -439,6 +445,215 @@ $this->info('Useful extra info.'); Terminal Output +## Verbosity Levels + +Commands support verbosity flags that control how much output is shown. Users pass flags when running a command: + +- No flag: Normal output (`Verbosity::NORMAL`) +- `-q`: Quiet mode — suppress non-critical output (`Verbosity::QUIET`) +- `-v`: Verbose mode — show additional diagnostic info (`Verbosity::VERBOSE`) +- `-vv`: Debug mode — maximum detail (`Verbosity::DEBUG`) + +Use the dedicated methods to output messages at specific verbosity levels: + +``` php +public function exec(): int { + $this->println("Always shown"); // Shown at all levels + $this->verbose("Connecting to DB..."); // Only shown with -v or -vv + $this->debug("Query: SELECT * FROM x"); // Only shown with -vv + + return 0; +} +``` + +## Progress Bars + +For long-running operations, display a progress bar: + +``` php +public function exec(): int { + $bar = $this->createProgressBar(100); + $bar->start(); + + for ($i = 0; $i < 100; $i++) { + // Do work... + usleep(50000); + $bar->advance(); + } + + $bar->finish(); + return 0; +} +``` + +For iterating over a collection with automatic progress: + +``` php +public function exec(): int { + $items = $this->getItemsToProcess(); + + $this->withProgressBar($items, function ($item) { + $this->processItem($item); + }, 'Processing items...'); + + return 0; +} +``` + +## Table Display + +Display data in formatted tables using the `table()` method: + +``` php +public function exec(): int { + $data = [ + ['Ibrahim', 'ibrahim@example.com', 'Admin'], + ['Ahmad', 'ahmad@example.com', 'User'], + ['Fatima', 'fatima@example.com', 'Editor'], + ]; + + $this->table($data, ['Name', 'Email', 'Role']); + + return 0; +} +``` + +With styling and column colorizers: + +``` php +$this->table($data, ['Name', 'Email', 'Status'], [ + 'style' => 'bordered', // bordered, compact, minimal + 'colorize' => [ + 'Status' => fn($v) => match($v) { + 'Active' => ['color' => 'green', 'bold' => true], + 'Inactive' => ['color' => 'red'], + default => [] + } + ] +]); +``` + +For complex tables, use `TableBuilder` directly: + +``` php +use WebFiori\Cli\Table\TableBuilder; + +$table = TableBuilder::create() + ->setHeaders(['ID', 'Product', 'Price']) + ->setData($products) + ->useStyle('bordered') + ->setMaxWidth(80); + +$this->println($table->render()); +``` + +## Signal Handling + +Handle POSIX signals (e.g., SIGINT for Ctrl+C) to perform cleanup: + +``` php +public function exec(): int { + $this->onSignal(SIGINT, function () { + $this->warning('Interrupted! Cleaning up...'); + $this->cleanup(); + exit(130); + }); + + // Long-running work... + while ($this->hasWork()) { + $this->processNextItem(); + } + + return 0; +} +``` + +> **Note:** Signal handling requires the `pcntl` PHP extension. On systems without it (e.g., Windows), signal handlers are silently ignored. + +## PHP 8 Attributes + +### `#[Group]` + +Organize commands into named groups in the help output: + +``` php +use WebFiori\Cli\Attributes\Group; +use WebFiori\Cli\Command; + +#[Group('database')] +class MigrateCommand extends Command { + public function __construct() { + parent::__construct('migrate', [], 'Run database migrations'); + } + + public function exec(): int { /* ... */ return 0; } +} +``` + +Commands with the same group name are displayed together in the help listing. + +### `#[SingleInstance]` + +Prevent concurrent execution of a command (e.g., a scheduler or queue worker): + +``` php +use WebFiori\Cli\Attributes\SingleInstance; +use WebFiori\Cli\Command; + +#[SingleInstance(exitCode: 1)] +class QueueWorkerCommand extends Command { + public function __construct() { + parent::__construct('queue:work', [], 'Process queue jobs'); + } + + public function exec(): int { + // Only one instance can run at a time. + // A second attempt exits immediately with code 1. + while (true) { + $this->processNextJob(); + } + return 0; + } +} +``` + +The lock is file-based using `flock()` and is automatically released on process exit or crash. + +## Testing Commands + +Use `CommandTestCase` to write unit tests for CLI commands: + +``` php +use WebFiori\Cli\CommandTestCase; + +class SayHiCommandTest extends CommandTestCase { + + public function testWithName() { + $this->executeCommand(new SayHiCommand(), [ + '--name' => 'Ibrahim' + ]); + + $this->assertEquals(0, $this->getExitCode()); + $this->assertOutputContains('Hi Ibrahim'); + } + + public function testInteractiveInput() { + $this->setInputs(['Ibrahim']); + + $this->executeCommand(new SayHiCommand(), []); + + $this->assertEquals(0, $this->getExitCode()); + $this->assertOutputContains('Hi Ibrahim'); + } +} +``` + +The test case provides input/output stream mocking — no actual terminal interaction needed. + +## Command Line Utility + +The framework provides a command to scaffold new CLI commands: `php webfiori create:command`. It prompts for the command name, arguments, and description, then generates the class file. + ## Related Articles * [Installation](learn/installation) - Use CLI during installation diff --git a/database-repository.md b/database-repository.md new file mode 100644 index 0000000..abef371 --- /dev/null +++ b/database-repository.md @@ -0,0 +1,468 @@ +# Repository Pattern + + + +In this page: +* [Introduction](#introduction) +* [Repository Pattern](#repository-pattern) + * [Creating an Entity](#creating-an-entity) + * [Creating a Repository](#creating-a-repository) + * [CRUD Operations](#crud-operations) + * [Custom Queries](#custom-queries) +* [Pagination](#pagination) + * [Offset-Based Pagination](#offset-based-pagination) + * [Cursor-Based Pagination](#cursor-based-pagination) +* [Eager Loading](#eager-loading) + * [Defining Relationships](#defining-relationships) + * [Using `with()` (Preload Strategy)](#using-with-preload-strategy) + * [Using `withJoin()` (Single Query)](#using-withjoin-single-query) +* [Active Record Pattern](#active-record-pattern) +* [Bulk Operations](#bulk-operations) +* [CLI Scaffolding](#cli-scaffolding) + +## Introduction + +The database library provides [`AbstractRepository`](https://webfiori.com/docs/WebFiori/Database/Repository/AbstractRepository) — a base class that handles common data access operations (CRUD, pagination, eager loading) so you don't have to write boilerplate SQL for each entity. + +Two patterns are supported: +- **Repository Pattern**: Separate entity and repository classes. Best for clean architecture and testability. +- **Active Record Pattern**: Entity and repository merged into one class. Best for rapid development and simple models. + +## Repository Pattern + +### Creating an Entity + +An entity is a plain PHP class representing your domain object. No framework dependencies: + +``` php +class Product { + public ?int $id = null; + public string $name; + public string $category; + public float $price; + public int $stock; + + public function __construct(string $name = '', string $category = '', float $price = 0, int $stock = 0) { + $this->name = $name; + $this->category = $category; + $this->price = $price; + $this->stock = $stock; + } +} +``` + +### Creating a Repository + +Extend `AbstractRepository` and implement four abstract methods: + +``` php +use WebFiori\Database\Repository\AbstractRepository; + +class ProductRepository extends AbstractRepository { + protected function getTableName(): string { + return 'products'; + } + + protected function getIdField(): string { + return 'id'; + } + + protected function toEntity(array $row): object { + $product = new Product(); + $product->id = (int) $row['id']; + $product->name = $row['name']; + $product->category = $row['category']; + $product->price = (float) $row['price']; + $product->stock = (int) $row['stock']; + return $product; + } + + protected function toArray(object $entity): array { + return [ + 'id' => $entity->id, + 'name' => $entity->name, + 'category' => $entity->category, + 'price' => $entity->price, + 'stock' => $entity->stock + ]; + } +} +``` + +| Method | Purpose | +|--------|---------| +| `getTableName()` | Returns the database table name | +| `getIdField()` | Returns the primary key column name | +| `toEntity(array $row)` | Maps a database row to an entity object | +| `toArray(object $entity)` | Maps an entity to an associative array for insert/update | + +### CRUD Operations + +``` php +$db = new Database($connectionInfo); +$repo = new ProductRepository($db); + +// Create +$product = new Product('Widget', 'Hardware', 29.99, 50); +$repo->save($product); + +// Read +$product = $repo->findById(1); +$allProducts = $repo->findAll(); +$count = $repo->count(); + +// Update (save detects existing ID and updates) +$product->price = 24.99; +$repo->save($product); + +// Delete +$repo->deleteById(1); +$repo->deleteAll(); + +// Reload from database +$fresh = $repo->reload($product); +``` + +The `save()` method is smart: if the entity has a non-null ID, it updates; otherwise, it inserts. + +### Custom Queries + +For queries beyond basic CRUD, use `getDatabase()` or `createQuery()`: + +``` php +class ProductRepository extends AbstractRepository { + // ... abstract methods ... + + public function findByCategory(string $category): array { + $result = $this->getDatabase()->table($this->getTableName()) + ->select() + ->where('category', $category) + ->execute(); + + return array_map(fn($row) => $this->toEntity($row), $result->fetchAll()); + } + + public function findLowStock(int $threshold = 10): array { + $result = $this->getDatabase()->table($this->getTableName()) + ->select() + ->where('stock', $threshold, '<') + ->execute(); + + return array_map(fn($row) => $this->toEntity($row), $result->fetchAll()); + } +} +``` + +## Pagination + +### Offset-Based Pagination + +Traditional page numbers. Good for UIs with "Page 1, 2, 3..." navigation: + +``` php +$page = $repo->paginate(page: 1, perPage: 20); + +echo $page->getTotalItems(); // Total records in table +echo $page->getTotalPages(); // Calculated total pages +echo $page->getCurrentPage(); // Current page number +echo $page->hasNextPage(); // bool +echo $page->hasPreviousPage(); // bool +echo $page->getNextPage(); // int or null +echo $page->getPreviousPage(); // int or null + +foreach ($page->getItems() as $product) { + echo $product->name; +} +``` + +With ordering: + +``` php +$page = $repo->paginate(page: 2, perPage: 10, orderBy: ['price' => 'DESC']); +``` + +### Cursor-Based Pagination + +Better for large datasets and infinite scroll. Uses a cursor (last seen value) instead of offset: + +``` php +// First page +$page = $repo->paginateByCursor(cursor: null, limit: 20, cursorColumn: 'id', direction: 'ASC'); + +foreach ($page->getItems() as $product) { + echo $product->name; +} + +echo $page->hasMore(); // bool +echo $page->getNextCursor(); // string (pass to next request) + +// Next page +$nextPage = $repo->paginateByCursor( + cursor: $page->getNextCursor(), + limit: 20, + cursorColumn: 'id', + direction: 'ASC' +); +``` + +Cursor-based pagination is more efficient than offset for large tables because it doesn't require counting all rows or skipping past them. + +## Eager Loading + +Eager loading solves the N+1 query problem. Without it, fetching 100 authors and their posts would require 101 queries (1 for authors + 100 for each author's posts). With eager loading, it takes 2 queries. + +### Defining Relationships + +Relationships are defined on the **table class** using attributes: + +**One-to-Many (HasMany):** + +``` php +use WebFiori\Database\Attributes\Column; +use WebFiori\Database\Attributes\HasMany; +use WebFiori\Database\Attributes\Table; +use WebFiori\Database\DataType; + +#[Table(name: 'authors')] +#[HasMany(entity: Post::class, foreignKey: 'author-id', property: 'posts', table: 'posts')] +class AuthorsTable { + #[Column(type: DataType::INT, primary: true, autoIncrement: true)] + public int $id; + + #[Column(type: DataType::VARCHAR, size: 100)] + public string $name; +} +``` + +**Many-to-One (BelongsTo via ForeignKey):** + +``` php +#[Table(name: 'posts')] +#[HasMany(entity: Comment::class, foreignKey: 'post-id', property: 'comments', table: 'comments')] +class PostsTable { + #[Column(type: DataType::INT, primary: true, autoIncrement: true)] + public int $id; + + #[Column(type: DataType::VARCHAR, size: 200)] + public string $title; + + #[Column(name: 'author-id', type: DataType::INT)] + #[ForeignKey(table: AuthorsTable::class, column: 'id', property: 'author', entity: Author::class)] + public int $authorId; +} +``` + +Key parameters: +- `#[HasMany]`: `entity` (target class), `foreignKey` (FK column in child table), `property` (property name on parent entity), `table` (child table name) +- `#[ForeignKey]` with `property` and `entity`: defines a belongsTo relationship alongside the FK constraint + +The repository must reference the table class via `getTableClass()`: + +``` php +class AuthorRepository extends AbstractRepository { + protected function getTableClass(): string { + return AuthorsTable::class; + } + + protected function getTableName(): string { + return 'authors'; + } + + protected function getIdField(): string { + return 'id'; + } + + protected function toEntity(array $row): object { + $author = new Author(); + $author->id = (int) $row['id']; + $author->name = $row['name']; + return $author; + } + + protected function toArray(object $entity): array { + return ['id' => $entity->id, 'name' => $entity->name]; + } +} +``` + +### Using `with()` (Preload Strategy) + +Loads related data using 1+N queries (1 per relation type, not per entity): + +``` php +// Load authors with their posts (2 queries: 1 for authors + 1 for all posts) +$authors = $authorRepo->with('posts')->findAll(); + +foreach ($authors as $author) { + echo $author->name; + foreach ($author->posts as $post) { + echo " - " . $post->title; + } +} + +// Multiple relations +$posts = $postRepo->with(['author', 'comments'])->findAll(); + +// Works with findById and paginate too +$author = $authorRepo->with('posts')->findById(1); +$page = $authorRepo->with('posts')->paginate(page: 1, perPage: 10); +``` + +### Using `withJoin()` (Single Query) + +For belongsTo relations only, uses a LEFT JOIN to load related data in a single query: + +``` php +// Load posts with their author (1 query with JOIN) +$posts = $postRepo->withJoin('author')->findAll(); + +foreach ($posts as $post) { + echo $post->title . " by " . $post->author->name; +} +``` + +`withJoin()` is more efficient (single query) but only works for belongsTo (many-to-one) relations. For hasMany (one-to-many), use `with()` — using a JOIN would create a cartesian product. + +``` php +// This throws RepositoryException — hasMany cannot be joined +$authorRepo->withJoin('posts')->findAll(); // ERROR +``` + +## Active Record Pattern + +For simpler projects, merge the entity and repository into a single class: + +``` php +use WebFiori\Database\Attributes\Column; +use WebFiori\Database\Attributes\Table; +use WebFiori\Database\Database; +use WebFiori\Database\DataType; +use WebFiori\Database\Repository\AbstractRepository; + +#[Table(name: 'articles')] +class Article extends AbstractRepository { + #[Column(type: DataType::INT, primary: true, autoIncrement: true)] + public ?int $id = null; + + #[Column(type: DataType::VARCHAR, size: 200)] + public string $title = ''; + + #[Column(type: DataType::TEXT)] + public string $content = ''; + + #[Column(name: 'author-name', type: DataType::VARCHAR, size: 100)] + public string $authorName = ''; + + public function __construct(Database $db) { + parent::__construct($db); + } + + // Custom query + public function findByAuthor(string $author): array { + $result = $this->getDatabase()->table($this->getTableName()) + ->select() + ->where('author-name', $author) + ->execute(); + + return array_map(fn($row) => $this->toEntity($row), $result->fetchAll()); + } + + protected function getTableName(): string { return 'articles'; } + protected function getIdField(): string { return 'id'; } + + protected function toEntity(array $row): object { + $article = new self($this->db); + $article->id = (int) $row['id']; + $article->title = $row['title']; + $article->content = $row['content']; + $article->authorName = $row['author-name'] ?? ''; + return $article; + } + + protected function toArray(object $entity): array { + return [ + 'id' => $entity->id, + 'title' => $entity->title, + 'content' => $entity->content, + 'author-name' => $entity->authorName, + ]; + } +} +``` + +Usage: + +``` php +// Create the table from attributes +$table = AttributeTableBuilder::build(Article::class, 'mysql'); +$db->addTable($table); +$db->table('articles')->createTable()->execute(); + +// Use the model +$article = new Article($db); +$article->title = 'Hello World'; +$article->content = 'My first article'; +$article->authorName = 'Ibrahim'; +$article->save(); + +// Query +$all = $article->findAll(); +$one = $article->findById(1); +$byAuthor = $article->findByAuthor('Ibrahim'); + +// Update +$article->title = 'Updated Title'; +$article->save(); + +// Delete +$article->deleteById(1); +``` + +**When to use which:** + +| | Repository Pattern | Active Record | +|---|---|---| +| Testability | High (inject mock DB) | Lower (tightly coupled) | +| Separation of concerns | Entity is pure, no DB knowledge | Entity knows about DB | +| Code amount | More files | Fewer files | +| Best for | Complex domains, team projects | Simple CRUD, prototypes | + +## Bulk Operations + +Save multiple entities in a single transaction: + +``` php +$products = [ + new Product('Widget A', 'Hardware', 10.00, 100), + new Product('Widget B', 'Hardware', 15.00, 50), + new Product('Widget C', 'Hardware', 20.00, 25), +]; + +$repo->saveAll($products); +``` + +`saveAll()` automatically batches inserts for new entities and updates for existing ones, wrapped in a transaction. + +## CLI Scaffolding + +The framework provides commands to generate repository-related classes: + +``` bash +# Create a repository class +php webfiori create:repository + +# Create a domain entity class +php webfiori create:entity + +# Create a complete CRUD resource (entity + table + repository + service) +php webfiori create:resource +``` + +The `create:resource` command is the quickest way to scaffold a full CRUD setup — it generates all the files needed for a working API endpoint with database access. + +## Related Topics + +* [Database](database.md) — Connections, query builder, table definitions +* [Migrations and Seeders](migrations.md) — Schema versioning +* [MVC Architecture](mvc.md) — Controllers, Repositories, and Entities +* [Web Services](web-services.md) — Expose repositories as APIs diff --git a/database.md b/database.md index e5670c4..b7ca9fb 100644 --- a/database.md +++ b/database.md @@ -4,18 +4,24 @@ In this page: * [Introduction](#introduction) -* [The Idea](#the-idea) -* [Initializing your Database](#initializing-your-database) - * [Adding Connection Information](#adding-connection-information) - * [Creating Database Tables](#creating-database-tables) +* [Supported Databases](#supported-databases) +* [Connecting to a Database](#connecting-to-a-database) + * [MySQL / MSSQL](#mysql--mssql) + * [SQLite](#sqlite) + * [Connection Pooling](#connection-pooling) +* [Creating Database Tables](#creating-database-tables) * [Using Blueprints](#using-blueprints) + * [Using Table Classes](#using-table-classes) * [Using PHP 8 Attributes](#using-php-8-attributes) - * [Creating Database Class](#creating-database-class) + * [Registering Tables from Classes](#registering-tables-from-classes) + * [Creating All Tables at Once](#creating-all-tables-at-once) +* [Creating Database Class](#creating-database-class) * [Database Queries](#database-queries) * [Insert Record](#insert-record) * [Update Record](#update-record) * [Delete Record](#delete-record) * [Select](#select) + * [Aggregate Functions](#aggregate-functions) * [Raw SQL Queries](#raw-sql-queries) * [Joins](#joins) * [Unions](#unions) @@ -23,58 +29,130 @@ In this page: * [Working With Result Set](#working-with-result-set) * [Retrieving Records](#retrieving-records) * [Mapping Records to Objects](#mapping-records-to-objects) +* [Dry-Run Mode](#dry-run-mode) * [Performance Monitoring](#performance-monitoring) * [Command Line Utilities](#command-line-utilities) - * [Adding Connection](#adding-connection) - * [Creating Database Table](#creating-database-table) - * [Initializing Database Table](#initializing-database-table) ## Introduction -One of the important features of any web application is to have a simple-unified interface at which the developer can use to access application database. WebFiori framework has an abstract layer that provides the developer with all needed tools to create databases and performs queries on them. Currently, the abstraction layer supports MySQL and MSSQL database but there are plans to support more in the future. +One of the important features of any web application is to have a simple-unified interface at which the developer can use to access application database. WebFiori framework has an abstract layer that provides the developer with all needed tools to create databases and perform queries on them. > **Note:** It is possible to connect to any database using PDO driver of PHP. The database layer helps in defining your database in easy way and also it helps in making the process of building SQL queries much simpler task. -## The Idea +## Supported Databases -Each table in your database is represented by the class [`Table`](https://webfiori.com/docs/WebFiori/Database/Table). Every table consist of columns and every column is represented by the class [`Column`](https://webfiori.com/docs/WebFiori/Database/Column). Each table must be part of a schema (or database). The database is represented by the class [`Database`](https://webfiori.com/docs/WebFiori/Database/Database). WebFiori framework has the class [`DB`](https://webfiori.com/docs/WebFiori/Framework/DB) which adds extra functionality to the class [`Database`](https://webfiori.com/docs/WebFiori/Database/Database). The database instance is used to connect to database and run queries on it. +| Database | Driver | Table Class | Column Class | +|----------|--------|-------------|--------------| +| MySQL | `mysql` | `MySQLTable` | `MySQLColumn` | +| MSSQL (SQL Server) | `mssql` | `MSSQLTable` | `MSSQLColumn` | +| SQLite | `sqlite` | `SQLiteTable` | `SQLiteColumn` | -In case of MySQL database, database tables represented by the class [`MySQLTable`](https://webfiori.com/docs/WebFiori/Database/MySql/MySQLTable) and table columns represented by the class [`MySQLColumn`](https://webfiori.com/docs/WebFiori/Database/MySql/MySQLColumn). In case of MSSQL, database tables represented by the class [`MSSQLTable`](https://webfiori.com/docs/WebFiori/Database/MsSql/MSSQLTable) and table columns represented by the class [`MSSQLColumn`](https://webfiori.com/docs/WebFiori/Database/MsSql/MSSQLColumn). +All table and column classes are in the `WebFiori\Database` namespace (sub-namespaces `MySql`, `MsSql`, `Sqlite`). -## Initializing your Database +## Connecting to a Database -The following set of steps will show you how to create your database structure and connect to the database and execute queries. Overall, there are 3 steps in the process: -* Adding connection information. -* Creating database tables as classes. -* Creating a class that acts as the database schema and adding tables to it. +### MySQL / MSSQL -### Adding Connection Information +Database connections are represented by the class [`ConnectionInfo`](https://webfiori.com/docs/WebFiori/Database/ConnectionInfo). Connection information is stored in the JSON configuration file at `[APP_DIR]/Config/app-config.json`. -Database connections are represented by the class [`ConnectionInfo`](https://webfiori.com/docs/WebFiori/Database/ConnectionInfo). Connection information is stored in the JSON configuration file at `[APP_DIR]/Config/app-config.json`. It is possible to store multiple connections. There are two ways to add connection information: editing the JSON config file directly or using the command line interface. +``` php +use WebFiori\Database\ConnectionInfo; +use WebFiori\Database\Database; -Adding connection information manually can be done by editing the `app-config.json` file. The CLI approach using `php webfiori add:db-connection` is recommended since connection information will be validated before being stored. +$connection = new ConnectionInfo('mysql', 'root', '123456', 'my_database', 'localhost', 3306); +$db = new Database($connection); +``` -### Creating Database Tables +For MSSQL: -MySQL Database tables represented by the class [`MySQLTable`](https://webfiori.com/docs/WebFiori/Database/MySql/MySQLTable). Each table in the database must be represented as a sub class of this class. There are two ways at which the developer can create a class that represent a database table. One is a manual way and the other one is to use command line interface. +``` php +$connection = new ConnectionInfo('mssql', 'sa', 'password', 'my_database', 'localhost', 1433); +$db = new Database($connection); +``` -To create a table class manually, developer have to create new class that extend the class [`MySQLTable`](https://webfiori.com/docs/WebFiori/Database/MySql/MySQLTable) and add columns to it as needed. Assuming that the developer would like to place database tables in the folder `App/Database` with namespace `App\Database`. Also, assuming that the developer would like to create a table for keeping contacts information. +There are two ways to add connection information to the framework: editing the `app-config.json` file directly, or using the CLI command `php webfiori add:db-connection` (recommended, since it validates the connection). -The [constructor](https://webfiori.com/docs/WebFiori/Database/MySql/MySQLTable#__construct) of the class accepts one parameter which is the name of the table as it appears in the database. Let's assume that the name of the table is `contacts`. +### SQLite -``` php -namespace App\Database; +SQLite requires no username/password. Pass the file path as the database name: -use WebFiori\Database\MySql\MySQLTable; +``` php +use WebFiori\Database\ConnectionInfo; +use WebFiori\Database\Database; -class ContactsTable extends MySQLTable { - public function __construct() { - parent::__construct('contacts'); - } -} +// File-based database +$connection = new ConnectionInfo('sqlite', '', '', '/path/to/database.db'); +$db = new Database($connection); + +// In-memory database (useful for testing) +$connection = new ConnectionInfo('sqlite', '', '', ':memory:'); +$db = new Database($connection); +``` + +SQLite uses the same query builder API as MySQL and MSSQL. Table blueprints and attribute-based tables work the same way — the library handles type mapping automatically (e.g., `DataType::INT` becomes `INTEGER`, `DataType::VARCHAR` becomes `TEXT`). + +### Connection Pooling + +The library includes a built-in connection pool that reuses idle connections instead of creating new ones on every request. This prevents "Too many connections" errors and reduces handshake overhead. + +Connection pooling works automatically — every `Database` instance acquires connections from a shared `ConnectionPool` singleton: + +``` php +use WebFiori\Database\ConnectionPool; + +// Connections are pooled automatically. No special setup needed. +$db1 = new Database($connection); +// ... use $db1 ... +$db1->close(); // Released back to pool (not destroyed) + +$db2 = new Database($connection); // Reuses the idle connection + +// Configure pool limits +ConnectionPool::getInstance()->setMaxTotal(50); // Max 50 active connections +ConnectionPool::getInstance()->setMaxPerKey(10); // Max 10 idle per host/db combo + +// Check pool status +echo ConnectionPool::getInstance()->getActiveCount(); +echo ConnectionPool::getInstance()->getIdleCount(); + +// Close all connections (useful in tests or shutdown) +ConnectionPool::getInstance()->closeAll(); ``` -After setting the name of the table, developer can start by adding columns to the table. There is more than one way to add columns to the table. The method [`MySQLTable::addColumns()`](https://webfiori.com/docs/WebFiori/Database/MySql/MySQLTable#addColumns) can be used to add multiple columns at once. The method accepts an associative array. The indices of the array are columns names and the value of each index is a sub associative array that holds column properties. +When a `Database` object is destroyed or `close()` is called, the connection is returned to the pool for reuse rather than being closed. + +## Creating Database Tables + +### Using Blueprints + +For quick table creation without defining a separate class: + +``` php +use WebFiori\Database\ColOption; +use WebFiori\Database\DataType; + +$db->createBlueprint('users')->addColumns([ + 'id' => [ + ColOption::TYPE => DataType::INT, + ColOption::PRIMARY => true, + ColOption::AUTO_INCREMENT => true + ], + 'username' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 50 + ], + 'email' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 150 + ] +]); + +$db->table('users')->createTable()->execute(); +``` + +### Using Table Classes + +Each table in the database can be represented as a class. For MySQL: ``` php namespace App\Database; @@ -99,20 +177,6 @@ class ContactsTable extends MySQLTable { ColOption::SIZE => 50, ColOption::NULL => false ], - 'age' => [ - ColOption::TYPE => DataType::INT, - ColOption::SIZE => 3, - ], - 'mobile' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 15, - ColOption::NULL => true - ], - 'phone' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 15, - ColOption::NULL => true - ], 'email' => [ ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 255, @@ -123,45 +187,15 @@ class ContactsTable extends MySQLTable { } ``` -This table will be used to store basic information about contacts. It will act as an interface between the application and the actual database table. - -### Using Blueprints - -For quick table creation without defining a separate class, use the `createBlueprint()` method: - -``` php -$db = new Database($connection); - -$db->createBlueprint('users')->addColumns([ - 'id' => [ - ColOption::TYPE => DataType::INT, - ColOption::PRIMARY => true, - ColOption::AUTO_INCREMENT => true - ], - 'username' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 50 - ], - 'email' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 150 - ] -]); - -// Create the table -$db->table('users')->createTable()->execute(); -``` - -This approach is useful for simple tables or when you don't need a reusable table class. - ### Using PHP 8 Attributes -You can define tables using PHP 8 attributes for a cleaner, more declarative approach: +Define tables declaratively with attributes: ``` php -namespace App\Infrastructure\Schema; +namespace App\Database; use WebFiori\Database\Attributes\Column; +use WebFiori\Database\Attributes\ForeignKey; use WebFiori\Database\Attributes\Table; use WebFiori\Database\DataType; @@ -173,7 +207,7 @@ class UserTable { } ``` -Then build the table using `AttributeTableBuilder`: +Build and register: ``` php use WebFiori\Database\Attributes\AttributeTableBuilder; @@ -183,46 +217,54 @@ $db->addTable($table); $db->table('users')->createTable()->execute(); ``` -You can also define foreign keys using attributes: +You can define foreign keys using attributes: ``` php -use WebFiori\Database\Attributes\Column; -use WebFiori\Database\Attributes\ForeignKey; -use WebFiori\Database\Attributes\Table; -use WebFiori\Database\DataType; - #[Table(name: 'posts')] -#[Column(name: 'id', type: DataType::INT, primary: true, autoIncrement: true)] -#[Column(name: 'title', type: DataType::VARCHAR, size: 200)] class PostTable { + #[Column(type: DataType::INT, primary: true, autoIncrement: true)] + public int $id; + + #[Column(type: DataType::VARCHAR, size: 200)] + public string $title; + #[Column(name: 'author-id', type: DataType::INT)] #[ForeignKey(table: 'users', column: 'id')] public int $authorId; } ``` -### Creating Database Class +### Registering Tables from Classes -After creating tables as classes, developer have to add them to an instance of the class [`Database`](https://webfiori.com/docs/WebFiori/Database/Database) which represents the actual database instance. WebFiori framework have the class [`DB`](https://webfiori.com/docs/WebFiori/Framework/DB) which adds extra functionality like the ability to automatically register multiple tables automatically. For this reason, the developer should use the class [`DB`](https://webfiori.com/docs/WebFiori/Framework/DB). Assuming that the name of the database class is `TestingDatabase`. +Instead of manually building tables, register them directly from class names: ``` php -namespace App\Database; +// Single class (works with attribute-based or Table subclasses) +$db->addTableFromClass(UserTable::class); -use WebFiori\Framework\DB; +// Multiple classes at once +$db->addTablesFromClasses([UserTable::class, PostTable::class, CommentTable::class]); +``` -class TestingDatabase extends DB { - public function __construct() { - parent::__construct('connection-00'); - } -} +If the class engine differs from the connection (e.g., a MySQL table class used with an SQLite connection), it is converted automatically. + +### Creating All Tables at Once + +Create all registered tables in dependency order (respects foreign keys): + +``` php +$db->addTablesFromClasses([UserTable::class, PostTable::class]); +$db->createTables(); // Creates in correct order ``` -The constructor of the class accepts one parameter which is the name of the connection that will be used by database instance. After that, database table classes must be registered in order to perform queries on them. To do that, the developer can use the method [`DB::addTable()`](https://webfiori.com/docs/WebFiori/Framework/DB#addTable) for registering single table or the method [`DB::register()`](https://webfiori.com/docs/WebFiori/Framework/DB#register) to add multiple tables which belongs to same namespace. + +## Creating Database Class + +After creating tables, add them to a [`Database`](https://webfiori.com/docs/WebFiori/Database/Database) instance. In the framework, extend the class [`DB`](https://webfiori.com/docs/WebFiori/Framework/DB): ``` php namespace App\Database; use WebFiori\Framework\DB; -use App\Database\ContactsTable; class TestingDatabase extends DB { public function __construct() { @@ -233,126 +275,146 @@ class TestingDatabase extends DB { } ``` -Now that the table is added, we can create an instance of the class `TestingDatabase` and start building queries as needed. +The constructor accepts the name of the connection (as stored in `app-config.json`). You can also use `register()` to add multiple tables from the same namespace at once. ## Database Queries -The library provides a query builder which can be used to build almost any type of query. All query builders extend the class [AbstractQuery](https://webfiori.com/docs/WebFiori/Database/AbstractQuery) which acts as a base query builder. It has many methods to support the process of building queries. Note that the class [`Database`](https://webfiori.com/docs/WebFiori/Database/Database) acts as an interface for this class. To get the query builder instance, use the method [`Database::getQueryGenerator()`](https://webfiori.com/docs/WebFiori/Database/Database#getQueryGenerator). +The library provides a query builder for constructing queries. All query builders extend [`AbstractQuery`](https://webfiori.com/docs/WebFiori/Database/AbstractQuery). ### Insert Record -The method [AbstractQuery::insert()](https://webfiori.com/docs/WebFiori/Database/AbstractQuery#insert) is used to build an insert query for MySQL and MSSQL database. The following code sample shows how to use that method to create an insert query in case of MySQL database. It is used in same way in case of MSSQL. - ``` php -$db = new TestingDatabase(); $db->table('contacts')->insert([ 'name' => 'Ibrahim BinAlshikh', - 'age' => 27, - 'mobile' => '+966554321000', - 'phone' => '+966136543456', 'email' => 'xyz@example.com' ])->execute(); - -// insert into `contacts` (`name`, `age`, `mobile`, `phone`, `email`) values ('Ibrahim BinAlshikh', 27, '+966554321000', '+966136543456', 'xyz@example.com'); ``` -It is possible to insert multiple records using one insert call as follows: +Insert multiple records: -``` php +``` php $db->table('contacts')->insert([ - 'cols' => ['name', 'age', 'mobile', 'phone', 'email'], + 'cols' => ['name', 'email'], 'values' => [ - ['Contact 1', 33, '055434323', '0137665765', '123@example.com'], - ['Contact 2', 22, '056246436', '0138732156', '1234@example.com'], - ['Contact 3', 48, '051297647', '0136523489', '12345@example.com'] + ['Contact 1', '1@example.com'], + ['Contact 2', '2@example.com'], + ['Contact 3', '3@example.com'] ] ])->execute(); +``` -// insert into `contacts` -// (`name`, `age`, `mobile`, `phone`, `email`) -// values -// ('Contact 1', 33, '055434323', '0137665765', '123@example.com'), -// ('Contact 2', 22, '056246436', '0138732156', '1234@example.com'), -// ('Contact 3', 48, '051297647', '0136523489', '12345@example.com'); +Get the last inserted ID: + +``` php +$db->table('contacts')->insert(['name' => 'New Contact'])->execute(); +$id = $db->getLastInsertId(); ``` ### Update Record -The method [AbstractQuery::update()](https://webfiori.com/docs/WebFiori/Database/AbstractQuery#update) is used to build update record query for MySQL and MSSQL database. The following code sample shows how to use that method to create an update record query with a condition. - ``` php -$db = new TestingDatabase(); $db->table('contacts')->update([ - 'age' => 44, 'email' => 'new-email@example.com' ])->where('name', 'Contact 1')->execute(); - -// update `contacts` set `age` = 44, `email` = 'new-email@example.com' where `contacts`.`name` = 'Contact 1' ``` ### Delete Record -The method [AbstractQuery::delete()](https://webfiori.com/docs/WebFiori/Database/AbstractQuery#delete) is used to build a delete record query for MySQL and MSSQL database. The following code sample shows how to use that method to create a delete record query with a condition. - ``` php -$db = new TestingDatabase(); $db->table('contacts')->delete()->where('name', 'Contact 1')->execute(); - -// delete from `contacts` where `contacts`.`name` = 'Contact 1' ``` ### Select -The method [AbstractQuery::select()](https://webfiori.com/docs/WebFiori/Database/AbstractQuery#select) is used to build a `select` query. ``` php -$db = new TestingDatabase(); -$db->table('contacts')->select()->execute(); +// Select all +$result = $db->table('contacts')->select()->execute(); + +// Select specific columns +$result = $db->table('contacts')->select(['name', 'email'])->execute(); -// select * from `contacts` +// Column aliases +$result = $db->table('contacts')->select([ + 'name' => 'full_name', + 'email' => 'contact_email' +])->execute(); ``` -> **Note:** After building the query, the method [`Database::execute()`](https://webfiori.com/docs/WebFiori/Database/Database#execute) or the method [`AbstractQuery::execute()`](https://webfiori.com/docs/WebFiori/Database/AbstractQuery#execute) must be called to run the query on the database. -The method [`Database::table()`](https://webfiori.com/docs/WebFiori/Database/Database#table) is used to specify the table at which the query will be based on. It is possible to select some columns by supplying an array that holds columns that will be selected. +Where conditions: ``` php -$db = new TestingDatabase(); -$db->table('contacts')->select(['name', 'age'])->execute(); +$result = $db->table('contacts')->select() + ->where('age', 15, '>') + ->andWhere('name', 'Ibrahim') + ->execute(); + +// OR condition +$result = $db->table('contacts')->select() + ->where('email', null, 'is not') + ->orWhere('mobile', null, 'is not') + ->execute(); +``` + +Ordering: -// select `name`, `age` from `contacts` +``` php +$result = $db->table('contacts')->select() + ->orderBy(['name' => 'ASC', 'age' => 'DESC']) + ->execute(); ``` -Also, it is possible to give an alias for the column using the following syntax. +Pagination with limit/offset: ``` php -$db = new TestingDatabase(); -$db->table('contacts')->select([ - 'name' => 'full_name', - 'age' => 'contact_age' -])->execute(); +$result = $db->table('contacts')->select() + ->limit(20) + ->offset(40) + ->execute(); + +// Or use the page helper +$result = $db->table('contacts')->select() + ->page(3, 20) // Page 3, 20 items per page + ->execute(); +``` -// select `name` as `full_name`, `age` as `contact_age` from `contacts` +Grouping: + +``` php +$result = $db->table('orders')->select(['status', 'count(*)' => 'total']) + ->groupBy('status') + ->execute(); ``` -Developer can also add a `where` condition to the query. There are 3 methods which can be used to add a where condition: -* [AbstractQuery::where()](https://webfiori.com/docs/WebFiori/Database/AbstractQuery#where) -* [AbstractQuery::orWhere()](https://webfiori.com/docs/WebFiori/Database/AbstractQuery#orWhere) -* [AbstractQuery::andWhere()](https://webfiori.com/docs/WebFiori/Database/AbstractQuery#andWhere) +### Aggregate Functions + +Convenience methods for common aggregates: ``` php -$db = new TestingDatabase(); -$db->table('contacts')->select()->where('age', 15, '>') - ->andWhere('name', 'Ibrahim')->execute(); +// Count +$result = $db->table('contacts')->selectCount()->execute(); +echo $result->fetch()['count']; + +// Count specific column with alias +$result = $db->table('contacts')->selectCount('email', 'email_count')->execute(); + +// Max +$result = $db->table('products')->selectMax('price')->execute(); +echo $result->fetch()['max']; + +// Min +$result = $db->table('products')->selectMin('price')->execute(); +echo $result->fetch()['min']; -// select * from `contacts` where `age` > 15 and `name` = 'Ibrahim' +// Average +$result = $db->table('products')->selectAvg('price')->execute(); +echo $result->fetch()['avg']; ``` ### Raw SQL Queries -For complex queries or database-specific features, use the `raw()` method to execute raw SQL: +For complex queries or database-specific features: ``` php -$db = new TestingDatabase(); - // Simple raw query $result = $db->raw("SELECT * FROM contacts WHERE age > 25")->execute(); @@ -362,12 +424,6 @@ $result = $db->raw( [25, '%Ibrahim%'] )->execute(); -// Insert with raw SQL -$db->raw( - "INSERT INTO contacts (name, email) VALUES (?, ?)", - ['John Doe', 'john@example.com'] -)->execute(); - // Complex queries $result = $db->raw(" SELECT c.name, COUNT(o.id) as order_count @@ -380,10 +436,7 @@ $result = $db->raw(" ### Joins -The library supports different types of joins including inner join, left join, right join, and full outer join. The method [`AbstractQuery::join()`](https://webfiori.com/docs/WebFiori/Database/AbstractQuery#join) is used to add joins to a query. - ``` php -$db = new TestingDatabase(); $db->table('contacts')->select([ 'contacts.name', 'contacts.email', @@ -394,12 +447,9 @@ $db->table('contacts')->select([ 'contacts.id' => 'orders.contact_id' ] ])->execute(); - -// select `contacts`.`name`, `contacts`.`email`, `orders`.`total` -// from `contacts` inner join `orders` on `contacts`.`id` = `orders`.`contact_id` ``` -You can also specify the join type: +Specify join type: ``` php $db->table('contacts')->select() @@ -414,50 +464,32 @@ $db->table('contacts')->select() ### Unions -The method [`AbstractQuery::union()`](https://webfiori.com/docs/WebFiori/Database/AbstractQuery#union) can be used to combine results from multiple select queries. - ``` php -$db = new TestingDatabase(); - -// First query $query1 = $db->table('contacts')->select(['name', 'email']) ->where('age', 25, '>'); -// Second query $query2 = $db->table('subscribers')->select(['name', 'email']) ->where('active', true); -// Union the queries $query1->union($query2)->execute(); - -// (select `name`, `email` from `contacts` where `age` > 25) -// union -// (select `name`, `email` from `subscribers` where `active` = 1) ``` ## Transactions -Transactions ensure that a group of database operations either all succeed or all fail together. Use the `transaction()` method for atomic operations: +Transactions ensure that a group of operations either all succeed or all fail: ``` php -$db = new TestingDatabase(); - $db->transaction(function (Database $db) { - // Deduct from sender - $sender = $db->table('accounts')->select()->where('id', 1)->execute()->fetch(); $db->table('accounts') - ->update(['balance' => $sender['balance'] - 100]) + ->update(['balance' => 900]) ->where('id', 1) ->execute(); - // Add to receiver - $receiver = $db->table('accounts')->select()->where('id', 2)->execute()->fetch(); $db->table('accounts') - ->update(['balance' => $receiver['balance'] + 100]) + ->update(['balance' => 1100]) ->where('id', 2) ->execute(); - // Log the transaction $db->table('transfers')->insert([ 'from_account' => 1, 'to_account' => 2, @@ -466,143 +498,84 @@ $db->transaction(function (Database $db) { }); ``` -Key features: -- **Automatic commit**: If the callback completes without exceptions, changes are committed -- **Automatic rollback**: If an exception is thrown, all changes are rolled back -- **Nested transactions**: Supported via savepoints - -Example with error handling: - -``` php -try { - $db->transaction(function (Database $db) { - $balance = $db->table('accounts') - ->select(['balance']) - ->where('id', 1) - ->execute() - ->fetch()['balance']; - - if ($balance < 500) { - throw new Exception('Insufficient funds'); - } - - // Proceed with transfer... - }); - echo "Transfer successful"; -} catch (Exception $e) { - echo "Transfer failed: " . $e->getMessage(); - // All changes have been rolled back automatically -} -``` +- If the callback completes without exceptions, changes are committed +- If an exception is thrown, all changes are rolled back +- Nested transactions are supported via savepoints ## Working With Result Set -After building the query, it must be executed on the database. To execute a query, the method [`AbstractQuery::execute()`](https://webfiori.com/docs/WebFiori/Database/AbstractQuery#execute) can be used. Some queries will not return a result but in case of select query, there will be. This section explains how to work with database query results. ### Retrieving Records -The `execute()` method returns a [`ResultSet`](https://webfiori.com/docs/WebFiori/Database/ResultSet) object directly for select queries, making it easy to work with results. - -``` php -$db = new TestingDatabase(); -$result = $db->table('contacts')->select()->execute(); - -foreach($result as $record) { - //Do something with the record - echo "Name: " . $record['name'] . ", Email: " . $record['email'] . "\n"; -} -``` - -You can also get specific information about the result set: +The `execute()` method returns a [`ResultSet`](https://webfiori.com/docs/WebFiori/Database/ResultSet) for select queries: ``` php $result = $db->table('contacts')->select()->execute(); -echo "Total records: " . $result->getRowsCount() . "\n"; -echo "Columns: " . implode(', ', $result->getColsNames()) . "\n"; +// Iterate +foreach ($result as $record) { + echo $record['name']; +} -// Get all rows as array -$allRows = $result->getRows(); +// Row count and column names +echo $result->getRowsCount(); +echo implode(', ', $result->getColsNames()); -// Alternative methods for fetching records -$firstRow = $result->fetch(); // Fetch a single row -$allRows = $result->fetchAll(); // Fetch all rows as array +// Fetch methods +$firstRow = $result->fetch(); // Single row +$allRows = $result->fetchAll(); // All rows as array ``` ### Mapping Records to Objects -It is possible to map the records to objects. To achieve this, the developer can use the method [`ResultSet::setMappingFunction()`](https://webfiori.com/docs/WebFiori/Database/ResultSet#setMappingFunction). This method is used to set a function which can use to manipulate the result set after fetching. The method must return an array that contains the records after mapping. +Use `setMappingFunction()` to transform rows into objects: ``` php -$db = new TestingDatabase(); $result = $db->table('contacts')->select()->execute(); -$result->setMappingFunction(function ($dataset){ +$result->setMappingFunction(function ($dataset) { $retVal = []; - foreach($dataset as $record) { - $contactObj = new Contact(); - $contactObj->setName($record['name']); - $contactObj->setAge($record['age']); - $contactObj->setMobile($record['mobile']); - $contactObj->setPhone($record['phone']); - $contactObj->setEmail($record['email']); - - $retVal[] = $contactObj; + foreach ($dataset as $record) { + $contact = new Contact(); + $contact->setName($record['name']); + $contact->setEmail($record['email']); + $retVal[] = $contact; } return $retVal; }); -foreach($result as $record) { - //Now the $record is an object of type Contact - echo "Contact: " . $record->getName() . "\n"; +foreach ($result as $contact) { + echo $contact->getName(); } ``` -### Advanced Query Examples - -Here are some more advanced examples of database operations: +> **Tip:** For a more structured approach to entity mapping, see the [Repository Pattern](database-repository.md) which provides built-in CRUD, pagination, and eager loading. -``` php -// Complex where conditions -$result = $db->table('contacts') - ->select() - ->where('age', 18, '>=') - ->andWhere('email', null, 'is not') - ->orWhere('mobile', null, 'is not') - ->execute(); +## Dry-Run Mode -// Ordering results -$result = $db->table('contacts') - ->select() - ->orderBy(['name' => 'ASC', 'age' => 'DESC']) - ->execute(); +Preview what SQL would be generated without executing it: -// Limiting results -$result = $db->table('contacts') - ->select() - ->limit(10, 20) // LIMIT 20 OFFSET 10 - ->execute(); +``` php +$db->setDryRun(true); -// Counting records -$result = $db->table('contacts') - ->select(['count(*)' => 'total_contacts']) - ->execute(); +$db->table('users')->insert(['name' => 'Test'])->execute(); // Not executed +$db->table('users')->select()->where('age', 25, '>')->execute(); // Not executed -foreach($result as $row) { - echo "Total contacts: " . $row['total_contacts'] . "\n"; +// Get all captured queries +$queries = $db->getCapturedQueries(); +foreach ($queries as $sql) { + echo $sql . "\n"; } ``` -## Performance Monitoring +This is useful for testing, debugging, and previewing migrations. -The database library includes built-in performance monitoring to help identify slow queries and optimize database operations. +## Performance Monitoring -### Enabling Performance Monitoring +Track and analyze query performance: ``` php use WebFiori\Database\Performance\PerformanceOption; -$db = new TestingDatabase(); - $db->setPerformanceConfig([ PerformanceOption::ENABLED => true, PerformanceOption::SLOW_QUERY_THRESHOLD => 100, // ms @@ -610,89 +583,60 @@ $db->setPerformanceConfig([ PerformanceOption::SAMPLING_RATE => 1.0, // 100% of queries PerformanceOption::MAX_SAMPLES => 1000 ]); -``` -### Analyzing Performance - -``` php -use WebFiori\Database\Performance\PerformanceAnalyzer; - -// Execute some queries... +// Execute queries... $db->table('users')->select()->execute(); -$db->table('orders')->select()->where('status', 'pending')->execute(); -// Get performance metrics +// Get metrics $analyzer = $db->getPerformanceMonitor()->getAnalyzer(); +echo "Total queries: " . $analyzer->getQueryCount(); +echo "Average time: " . $analyzer->getAverageTime() . " ms"; +echo "Slow queries: " . $analyzer->getSlowQueryCount(); -echo "Total queries: " . $analyzer->getQueryCount() . "\n"; -echo "Total time: " . $analyzer->getTotalTime() . " ms\n"; -echo "Average time: " . $analyzer->getAverageTime() . " ms\n"; -echo "Slow queries: " . $analyzer->getSlowQueryCount() . "\n"; -echo "Efficiency: " . $analyzer->getEfficiency() . "%\n"; -``` - -### Identifying Slow Queries - -``` php +// Identify slow queries $slowQueries = $analyzer->getSlowQueries(); - foreach ($slowQueries as $metric) { - echo "Query: " . $metric->getQuery() . "\n"; - echo "Time: " . $metric->getExecutionTimeMs() . " ms\n"; - echo "Rows: " . $metric->getRowsAffected() . "\n"; + echo $metric->getQuery() . " — " . $metric->getExecutionTimeMs() . " ms"; } -``` -### Performance Score +// Clear collected metrics +$db->clearPerformanceMetrics(); +``` -The analyzer provides a performance score: +You can also enable/disable monitoring dynamically: ``` php -$score = $analyzer->getScore(); - -switch ($score) { - case PerformanceAnalyzer::SCORE_EXCELLENT: - echo "Excellent performance!"; - break; - case PerformanceAnalyzer::SCORE_GOOD: - echo "Good performance"; - break; - case PerformanceAnalyzer::SCORE_NEEDS_IMPROVEMENT: - echo "Consider optimizing slow queries"; - break; -} +$db->enablePerformanceMonitoring(); +// ... queries here are tracked ... +$db->disablePerformanceMonitoring(); ``` ## Command Line Utilities -WebFiori framework provides extra commands using CLI which are related to database management. The commands can be used to automate some of the repetitive tasks such as creating new database table. In this section, you will find a summary about the available commands. - -### Adding Connection Using Command Line Interface - -This way of adding database connections is recommended since connection information will be first validated before stored. To add new connection, simply run the command `php webfiori add:db-connection`. The following image shows the whole process of adding the connection using CLI. - -Add connection command. - -### Creating Database Table - -It is recommended to use command line interface in creating table classes. By using CLI, you only have to give database table properties as inputs and the class will be created automatically for you. To create a new database table class, simply run the command `php webfiori create:table`. - -Add database table command. - -### Initializing Database Table - -The command `php webfiori create:table` can be also used to initialize the table in database by selecting another option. - -Initialize database table command. - -Initialize database table command. - -## Related Articles - -* [MVC Architecture](learn/mvc) - Build APIs with Controllers, Repositories, and Entities -* [Migrations and Seeders](learn/migrations) - Manage schema changes and seed data -* [Web Services](learn/web-services) - Use database in API endpoints -* [Command Line Interface](learn/command-line-interface) - Manage database via CLI -* [The Library WebFiori JSON](learn/webfiori-json) - Convert database results to JSON -* [Sessions Management](learn/sessions-management) - Store session data in database -* [Background Tasks](learn/background-tasks) - Process database operations asynchronously +WebFiori framework provides CLI commands for database management: + +| Command | Description | +|---------|-------------| +| `php webfiori add:db-connection` | Add a new database connection (validates before saving) | +| `php webfiori create:table` | Create a new database table schema class | +| `php webfiori create:repository` | Create a new repository class | +| `php webfiori create:entity` | Create a new domain entity class | +| `php webfiori create:resource` | Create a complete CRUD resource (entity, table, repository, service) | +| `php webfiori create:migration` | Create a new migration class | +| `php webfiori create:seeder` | Create a new seeder class | +| `php webfiori migrations:run` | Execute pending migrations | +| `php webfiori migrations:rollback` | Roll back migrations | +| `php webfiori migrations:status` | Show migration status | +| `php webfiori migrations:dry-run` | Preview pending migrations without executing | +| `php webfiori migrations:fresh` | Rollback all and run fresh | +| `php webfiori migrations:skip` | Mark migrations as applied without executing (baseline) | +| `php webfiori migrations:step` | Interactively apply or skip one at a time | +| `php webfiori migrations:ini` | Create the migrations tracking table | + +## Related Topics + +* [Repository Pattern](database-repository.md) — Repository, Active Record, eager loading, and pagination +* [Migrations and Seeders](migrations.md) — Schema versioning and data seeding +* [MVC Architecture](mvc.md) — Build APIs with Controllers, Repositories, and Entities +* [Web Services](web-services.md) — Use database in API endpoints +* [Command Line Interface](command-line-interface.md) — All CLI commands diff --git a/index.md b/index.md index 3c84b0a..2505e19 100644 --- a/index.md +++ b/index.md @@ -26,11 +26,14 @@ Here you will find topics which can help you to get started with WebFiori framew ## Data & Storage * [Database Management](learn/database) - Database operations +* [Repository Pattern](learn/database-repository) - Repository, Active Record, eager loading, pagination * [Migrations and Seeders](learn/migrations) - Schema versioning and data seeding * [Sessions Management](learn/sessions-management) - User session handling * [Caching](learn/caching) - Key-value cache with TTL, storage backends, and route caching * [The Library WebFiori JSON](learn/webfiori-json) - JSON data handling * [Uploading Files](learn/uploading-files) - File upload management +* [Streaming Uploads](learn/streaming-uploads) - Raw body uploads in constant memory +* [Resumable Uploads](learn/resumable-uploads) - Chunked uploads with pause/resume ## Advanced Topics * [MVC Architecture](learn/mvc) - API Controllers, Repositories, and Entities diff --git a/middleware.md b/middleware.md index 7d38d5b..a10c464 100644 --- a/middleware.md +++ b/middleware.md @@ -8,6 +8,9 @@ In this page: * [Assigning Middleware to Routes](#assigning-middleware-to-routes) * [Middleware Groups](#middleware-groups) * [Priority](#priority) + * [Dependencies](#dependencies) + * [Instantiable (Parameterized) Middleware](#instantiable-parameterized-middleware) + * [Registering Middleware Programmatically](#registering-middleware-programmatically) * [Command Line Utility](#command-line-utility) ## Introduction @@ -23,9 +26,14 @@ The following image shows how middleware works in general. The green request rep ## The Class [`AbstractMiddleware`](https://webfiori.com/docs/WebFiori/Framework/Middleware/AbstractMiddleware) Middleware represented by the class [`AbstractMiddleware`](https://webfiori.com/docs/WebFiori/Framework/Middleware/AbstractMiddleware). The class has abstract methods at which the developer must implement to have a functional middleware. The methods are: -* [AbstractMiddleware::before()](https://webfiori.com/docs/WebFiori/Framework/Middleware/AbstractMiddleware#before) -* [AbstractMiddleware::after()](https://webfiori.com/docs/WebFiori/Framework/Middleware/AbstractMiddleware#after) -* [AbstractMiddleware::afterSend()](https://webfiori.com/docs/WebFiori/Framework/Middleware/AbstractMiddleware#afterSend) +* [AbstractMiddleware::before()](https://webfiori.com/docs/WebFiori/Framework/Middleware/AbstractMiddleware#before) — Runs before the request is processed. Can reject the request. +* [AbstractMiddleware::after()](https://webfiori.com/docs/WebFiori/Framework/Middleware/AbstractMiddleware#after) — Runs after request processing but before sending the response. Can modify the response. +* [AbstractMiddleware::afterSend()](https://webfiori.com/docs/WebFiori/Framework/Middleware/AbstractMiddleware#afterSend) — Runs after the response is sent. For cleanup, logging, etc. + +Other key methods: +* `getDependencies()` — Returns middleware names this one depends on (auto-resolved). +* `setPriority(int)` — Sets execution priority (higher = earlier). +* `addToGroup(string)` / `addToGroups(array)` — Assigns to named groups. ## Implementing Custom Middleware @@ -191,6 +199,81 @@ class MyMiddleware extends AbstractMiddleware { If two middleware having same priority and no dependency relationship, they execute in the order they were assigned to the route. In case of response (`after()`), the order of execution is reversed. +### Dependencies + +Middleware can declare dependencies on other middleware via `getDependencies()`. The framework automatically resolves the dependency chain — you only need to assign the "leaf" middleware to a route: + +``` php +namespace App\Middleware; + +use WebFiori\Framework\Middleware\AbstractMiddleware; +use WebFiori\Http\Request; +use WebFiori\Http\Response; + +class AuthMiddleware extends AbstractMiddleware { + + public function __construct() { + parent::__construct('auth'); + } + + public function getDependencies(): array { + return ['start-session']; // start-session runs before this + } + + public function before(Request $request, Response $response) { + // Session is guaranteed to be started here + } + + public function after(Request $request, Response $response) {} + public function afterSend(Request $request, Response $response) {} +} +``` + +When `auth` is assigned to a route, `start-session` is automatically pulled in and executed first — even if you didn't list it in the route's middleware array. + +### Instantiable (Parameterized) Middleware + +Middleware can accept constructor parameters for configuration. Pass instances directly to routes: + +``` php +use WebFiori\Framework\Router\Router; +use WebFiori\Framework\Router\RouteOption; +use WebFiori\Framework\Middleware\RateLimitMiddleware; +use WebFiori\Framework\Middleware\CorsMiddleware; + +Router::api([ + RouteOption::PATH => '/apis/{service}', + RouteOption::TO => MyManager::class, + RouteOption::MIDDLEWARE => [ + 'start-session', // by name (from registry) + new RateLimitMiddleware(maxRequests: 100, windowSeconds: 60), // by instance + new CorsMiddleware(['origins' => ['https://app.example.com']]), + ] +]); +``` + +This allows different routes to use the same middleware class with different configurations. + +### Registering Middleware Programmatically + +Middleware placed in `[APP_DIR]/Middleware` is auto-discovered. To register middleware programmatically (e.g., from a package or with custom logic): + +``` php +use WebFiori\Framework\Middleware\MiddlewareManager; + +// Register by class name +MiddlewareManager::register(App\Middleware\MyMiddleware::class); + +// Register an instance +MiddlewareManager::register(new RateLimitMiddleware(maxRequests: 30, windowSeconds: 60)); + +// Retrieve by name +$mw = MiddlewareManager::getMiddleware('auth'); + +// Get all middleware in a group +$webMiddleware = MiddlewareManager::getGroup('web'); +``` + ### Practical Middleware Examples Here are some common middleware implementations: diff --git a/migrations.md b/migrations.md index 4f38341..e5bc87d 100644 --- a/migrations.md +++ b/migrations.md @@ -338,6 +338,43 @@ php webfiori migrations:dry-run --connection=main This shows pending migrations and the SQL queries they would execute. +Programmatically, you can use `getPendingChanges()` to preview pending migrations with their SQL: + +``` php +$pending = $runner->getPendingChanges(true); // true = include SQL queries + +foreach ($pending as $info) { + echo $info['name'] . "\n"; + foreach ($info['queries'] as $sql) { + echo " " . $sql . "\n"; + } +} +``` + +### Step-by-Step Execution + +To apply migrations one at a time (useful for debugging or gradual deployment): + +``` bash +php webfiori migrations:step --connection=main +``` + +Programmatically: + +``` php +// Apply a single pending migration +$change = $runner->applyOne(); + +// Skip the next N pending migrations +$skipped = $runner->skipNext(3); + +// Skip all remaining pending migrations +$skipped = $runner->skipAll(); + +// Skip everything up to (and including) a specific migration +$skipped = $runner->skipUpTo(CreateProductsTable::class); +``` + ## Rolling Back Migrations ### Rollback Last Batch diff --git a/resumable-uploads.md b/resumable-uploads.md new file mode 100644 index 0000000..1ea7c72 --- /dev/null +++ b/resumable-uploads.md @@ -0,0 +1,262 @@ +# Resumable Uploads + +In this page: +* [Introduction](#introduction) +* [When to Use](#when-to-use) +* [How It Works](#how-it-works) +* [Basic Usage](#basic-usage) +* [Checking Offset (Resume)](#checking-offset-resume) +* [Canceling an Upload](#canceling-an-upload) +* [Cleaning Up Stale Partials](#cleaning-up-stale-partials) +* [Custom Partial Directory](#custom-partial-directory) +* [Callbacks and Stream Processors](#callbacks-and-stream-processors) +* [Frontend Example](#frontend-example) +* [Full API Example](#full-api-example) + +## Introduction + +[`ResumableUploader`](https://webfiori.com/docs/WebFiori/File/ResumableUploader) handles chunked file uploads with resume-on-failure support. Each chunk is a separate HTTP request. If the connection drops, the client can query the server for the current byte offset and resume from where it left off. + +No database or session storage is needed — the partial file's size on disk serves as the authoritative byte offset. + +It extends [`AbstractUploader`](https://webfiori.com/docs/WebFiori/File/AbstractUploader), so all shared features (extension filtering, size limits, callbacks, stream processors) are available. See [Uploading Files](uploading-files.md#shared-features) for details. + +## When to Use + +- Large file uploads over unreliable networks (mobile, satellite, poor WiFi) +- Files that are too large for a single HTTP request timeout +- When users need progress indicators and the ability to pause/resume +- When server-side memory constraints prevent loading entire files + +If the network is reliable and files are small, [`StreamingUploader`](streaming-uploads.md) is simpler. For standard HTML forms, use [`FileUploader`](uploading-files.md). + +## How It Works + +1. Client generates a unique **upload ID** (e.g., UUID) for the session +2. Client splits the file into chunks and sends each as a separate request +3. Server appends each chunk to a partial file in a `.partial/` subdirectory +4. On failure, client asks the server for the current offset and resumes +5. On the final chunk, the server moves the partial file to the upload directory + +``` +Client Server + | | + |--- Chunk 1 (bytes 0-8191) ------->| append to .partial/id_file.dat + |<-- { offset: 8192 } --------------| + | | + |--- Chunk 2 (bytes 8192-16383) --->| append + |<-- { offset: 16384 } -------------| + | | + | *** connection drops *** | + | | + |--- GET offset? ------------------->| check filesize + |<-- { offset: 16384 } -------------| + | | + |--- Chunk 3 (final) -------------->| append + move to uploads/ + |<-- { complete: true, file } ------| +``` + +## Basic Usage + +``` php +use WebFiori\File\ResumableUploader; + +$uploader = new ResumableUploader('/home/files/uploads', ['mp4', 'zip']); + +// Each request provides the upload ID, filename, and whether it's the last chunk +$result = $uploader->receiveChunk( + uploadId: 'abc-123-def', + filename: 'large-video.mp4', + isLast: false +); + +// $result structure: +// [ +// 'offset' => 8192, // bytes received so far +// 'complete' => false, // not done yet +// 'file' => null // only set when complete +// ] +``` + +On the final chunk: + +``` php +$result = $uploader->receiveChunk('abc-123-def', 'large-video.mp4', true); + +// [ +// 'offset' => 524288, +// 'complete' => true, +// 'file' => UploadedFile instance +// ] +``` + +## Checking Offset (Resume) + +When a client reconnects after a failure, it queries the current offset: + +``` php +$uploader = new ResumableUploader('/home/files/uploads'); +$offset = $uploader->getOffset('abc-123-def', 'large-video.mp4'); + +// Returns 0 if no partial file exists, otherwise the byte count +``` + +The client then skips to that offset and resumes sending. + +## Canceling an Upload + +Remove the partial file for a given session: + +``` php +$uploader->cancel('abc-123-def', 'large-video.mp4'); +``` + +## Cleaning Up Stale Partials + +Remove partial files older than a given age. Useful as a scheduled task: + +``` php +$uploader = new ResumableUploader('/home/files/uploads'); +$removed = $uploader->cleanStale(3600); // remove partials older than 1 hour +echo "$removed stale files cleaned up"; +``` + +## Custom Partial Directory + +By default, partial files are stored in `.partial/` inside the upload directory. You can change this: + +``` php +$uploader->setPartialDir('/tmp/upload-partials'); +``` + +## Callbacks and Stream Processors + +The before-upload callback fires only on the **first chunk** of a session. The after-upload callback fires when the final chunk completes. + +``` php +$uploader->setOnBeforeUpload(function (array $fileInfo): bool { + // $fileInfo includes 'name', 'upload-path', and 'upload-id' + return isAllowedUser($fileInfo['upload-id']); +}); + +$uploader->setOnAfterUpload(function (UploadedFile $file): void { + notifyUser('Upload complete: ' . $file->getName()); +}); +``` + +Stream processors run during finalization — the partial file is read through the processor and written to the final destination: + +``` php +$uploader->setStreamProcessor(function (Generator $chunks, string $destPath): void { + $dest = fopen($destPath, 'wb'); + foreach ($chunks as $chunk) { + fwrite($dest, $chunk); + } + fclose($dest); +}); +``` + +## Frontend Example + +JavaScript client with chunked upload and resume: + +``` javascript +const CHUNK_SIZE = 64 * 1024; // 64KB chunks +const uploadId = crypto.randomUUID(); + +async function uploadFile(file) { + let offset = await getOffset(uploadId, file.name); + + while (offset < file.size) { + const isLast = (offset + CHUNK_SIZE) >= file.size; + const chunk = file.slice(offset, offset + CHUNK_SIZE); + + const response = await fetch('/api/upload/chunk', { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Upload-Id': uploadId, + 'X-Filename': file.name, + 'X-Is-Last': isLast ? '1' : '0' + }, + body: chunk + }); + + const result = await response.json(); + offset = result.offset; + + if (result.complete) { + console.log('Upload complete:', result.file); + return; + } + } +} + +async function getOffset(uploadId, filename) { + const response = await fetch(`/api/upload/offset?id=${uploadId}&name=${filename}`); + const data = await response.json(); + return data.offset; +} +``` + +## Full API Example + +A backend API handling chunk uploads, offset queries, and cancellation: + +``` php +use WebFiori\File\Exceptions\FileException; +use WebFiori\File\ResumableUploader; + +$uploader = new ResumableUploader('/home/files/uploads', ['mp4', 'zip', 'pdf']); +$uploader->setMaxFileSize(500 * 1024 * 1024); // 500MB + +$method = $_SERVER['REQUEST_METHOD']; +$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); + +if ($method === 'GET' && $path === '/api/upload/offset') { + // Resume check + $uploadId = $_GET['id'] ?? ''; + $filename = $_GET['name'] ?? ''; + $offset = $uploader->getOffset($uploadId, $filename); + + header('Content-Type: application/json'); + echo json_encode(['offset' => $offset]); + +} elseif ($method === 'POST' && $path === '/api/upload/chunk') { + // Receive chunk + $uploadId = $_SERVER['HTTP_X_UPLOAD_ID'] ?? ''; + $filename = $_SERVER['HTTP_X_FILENAME'] ?? null; + $isLast = ($_SERVER['HTTP_X_IS_LAST'] ?? '0') === '1'; + + try { + $result = $uploader->receiveChunk($uploadId, $filename, $isLast); + + header('Content-Type: application/json'); + http_response_code($result['complete'] ? 201 : 200); + echo json_encode([ + 'offset' => $result['offset'], + 'complete' => $result['complete'], + 'file' => $result['file'] ? $result['file']->getName() : null, + ]); + } catch (FileException $e) { + http_response_code(422); + header('Content-Type: application/json'); + echo json_encode(['error' => $e->getMessage()]); + } + +} elseif ($method === 'DELETE' && $path === '/api/upload/cancel') { + // Cancel upload + $uploadId = $_GET['id'] ?? ''; + $filename = $_GET['name'] ?? ''; + $uploader->cancel($uploadId, $filename); + + http_response_code(204); +} +``` + +## Related Topics + +* [Uploading Files](uploading-files.md) — Overview and `FileUploader` (multipart form uploads) +* [Streaming Uploads](streaming-uploads.md) — Single-shot raw body uploads +* [Background Tasks](background-tasks.md) — Schedule stale partial cleanup +* [Web Services](web-services.md) — Create upload APIs diff --git a/sending-emails.md b/sending-emails.md index 706a326..3f55d10 100644 --- a/sending-emails.md +++ b/sending-emails.md @@ -10,6 +10,8 @@ In this page: * [SMTP Setup For Common Servers](#smtp-setup-for-common-servers) * [GMail SMTP Server](#gmail-smtp-server) * [Outlook SMTP Server](#outlook-smtp-server) +* [Custom Transport](#custom-transport) + * [Send Modes](#send-modes) ## Introduction Email messages are considered as one of the most effective communication ways, and at some point, any website or web application will have to use them. WebFiori Framework has all needed tools to allow the application to be able to send HTML emails. Email messages are used in many ways. For example, they are used to activate user account, reset password, send news letters, etc... @@ -329,6 +331,74 @@ When connecting to Gmail SMTP server, it is noticed that in some cases it fail e In order to connect to Outlook SMTP, the developer must first generate an app password and use it as login password when adding the SMTP account. For more information on app passwords, check [here](https://support.microsoft.com/en-us/account-billing/using-app-passwords-with-apps-that-don-t-support-two-step-verification-5896ed9b-4263-e681-128a-a6f2979a7944). +## Custom Transport + +The library supports a pluggable transport architecture via `TransportInterface`. By default, emails are sent using `SmtpTransport`, but you can implement your own transport for API-based providers (SES, SendGrid, etc.) or for testing. + +### Using a Custom Transport + +``` php +use WebFiori\Mail\Email; +use WebFiori\Mail\TransportInterface; + +// Pass a transport to send() +$email = new Email($smtpAccount); +$email->setSubject('Hello'); +$email->addTo('user@example.com'); +$email->send(new MyCustomTransport()); +``` + +### Implementing a Transport + +``` php +use WebFiori\Mail\Email; +use WebFiori\Mail\TransportInterface; +use WebFiori\Mail\Exceptions\SMTPException; + +class NullTransport implements TransportInterface { + private array $sent = []; + + public function getName(): string { + return 'null'; + } + + public function send(Email $message): void { + // Capture instead of sending — useful for testing + $this->sent[] = $message; + } + + public function getSentMessages(): array { + return $this->sent; + } +} +``` + +This makes testing email functionality simple — inject a `NullTransport` and assert on captured messages without hitting an SMTP server. + +### Send Modes + +The library supports different send modes via `SendMode`: + +| Mode | Description | +|------|-------------| +| `SendMode::PROD` | Send to actual recipients (default) | +| `SendMode::TEST_SEND` | Send to configured test addresses instead of real recipients | +| `SendMode::TEST_STORE` | Store the email to disk instead of sending (for local dev) | + +``` php +use WebFiori\Mail\SendMode; + +$email->setMode(SendMode::TEST_STORE, [ + 'store-path' => '/tmp/emails' +]); +$email->send(); // Writes to /tmp/emails instead of sending + +$email->setMode(SendMode::TEST_SEND, [ + 'send-addresses' => 'qa@example.com;dev@example.com' +]); +$email->send(); // Sends to QA addresses instead of real recipients +``` + ## Related Articles * [Command Line Interface](learn/command-line-interface) - Configure SMTP settings via CLI diff --git a/sessions-management.md b/sessions-management.md index 7dac84f..458a3ce 100644 --- a/sessions-management.md +++ b/sessions-management.md @@ -12,6 +12,7 @@ In this page: * [Adding Data to a Session](#adding-data-to-a-session) * [Retrieving Stored Data](#retrieving-stored-data) * [Generating New ID](#generating-new-id) +* [Garbage Collection](#garbage-collection) * [Creating Custom Sessions Storage](#creating-custom-sessions-storage) * [Configuring Database Session Storage](#configuring-database-session-storage) * [Using Cache Session Storage (Redis)](#using-cache-session-storage-redis) @@ -175,6 +176,35 @@ SessionsManager::newId(); App::getResponse()->write('New Session ID: '.SessionsManager::getActiveSession()->getId().'
'); ``` +## Garbage Collection + +Expired sessions are cleaned up automatically using probabilistic garbage collection (similar to PHP's native session GC). By default, GC runs with a probability of 1/1000 on each request. + +Configure GC behavior: + +``` php +// Set probability: GC runs with probability/divisor chance on each request +// Default: 1/1000 (0.1% chance per request) +SessionsManager::setGCProbability(1, 100); // 1% chance per request + +// Limit how many expired sessions are cleaned per GC run +// Useful for large session stores to prevent long pauses +SessionsManager::setGCBatchSize(50); // Clean at most 50 sessions per run + +// Check current settings +echo SessionsManager::getGCProbability(); // numerator +echo SessionsManager::getGCDivisor(); // denominator +echo SessionsManager::getGCBatchSize(); // max per run (0 = unlimited) +``` + +To disable GC entirely (e.g., if you handle cleanup externally via cron): + +``` php +SessionsManager::setGCProbability(0, 0); +``` + +You can also set the `SESSION_GC` environment variable to control the expiry threshold in seconds. + ## Creating Custom Sessions Storage By default, the framework will use default sessions storage engine which is represented by the class [`DefaultSessionStorage`](https://webfiori.com/docs/WebFiori/Framework/Session/DefaultSessionStorage). This storage engine will store all session data in files which will be found in the directory `[APP_DIR]/Storage/Sessions`. diff --git a/streaming-uploads.md b/streaming-uploads.md new file mode 100644 index 0000000..9ebd575 --- /dev/null +++ b/streaming-uploads.md @@ -0,0 +1,188 @@ +# Streaming Uploads + +In this page: +* [Introduction](#introduction) +* [When to Use](#when-to-use) +* [Basic Usage](#basic-usage) +* [Filename Resolution](#filename-resolution) +* [Size Limits](#size-limits) +* [Stream Processors](#stream-processors) +* [Callbacks](#callbacks) +* [Frontend Example](#frontend-example) +* [Full API Example](#full-api-example) + +## Introduction + +[`StreamingUploader`](https://webfiori.com/docs/WebFiori/File/StreamingUploader) receives a single file from the raw HTTP body (`php://input`) in constant memory. Unlike `FileUploader` which works with `$_FILES` and multipart form data, this class reads binary data directly from the input stream — no temp file overhead, no memory spikes. + +It extends [`AbstractUploader`](https://webfiori.com/docs/WebFiori/File/AbstractUploader), so all shared features (extension filtering, size limits, callbacks, stream processors) are available. See [Uploading Files](uploading-files.md#shared-features) for details on those. + +## When to Use + +- JavaScript `fetch()` or `XMLHttpRequest` sending a file as raw body +- Mobile apps uploading binary data directly +- Large single-file uploads where you want constant memory usage +- When you need to process the stream during upload (hashing, encryption) + +If you need multipart form uploads, use [`FileUploader`](uploading-files.md). If you need chunked resume support, use [`ResumableUploader`](resumable-uploads.md). + +## Basic Usage + +``` php +use WebFiori\File\StreamingUploader; + +$uploader = new StreamingUploader('/home/files/uploads', ['mp4', 'mov', 'avi']); +$file = $uploader->receive('user-video.mp4'); + +// $file is an UploadedFile instance +echo $file->getName(); // user-video.mp4 +echo $file->isUploaded(); // true +echo $file->getAbsolutePath(); // /home/files/uploads/user-video.mp4 +``` + +The `receive()` method: +1. Resolves the filename (from parameter, headers, or default) +2. Sanitizes the filename +3. Validates extension and size +4. Fires the before-upload callback +5. Reads from `php://input` and writes to disk +6. Fires the after-upload callback +7. Returns an `UploadedFile` instance + +If any validation fails, a `FileException` is thrown. + +## Filename Resolution + +The filename is resolved in this order: + +1. **Explicit parameter**: `$uploader->receive('my-file.pdf')` +2. **`X-Filename` header**: The client sends `X-Filename: my-file.pdf` +3. **`Content-Disposition` header**: `Content-Disposition: attachment; filename="my-file.pdf"` +4. **Default**: `upload.bin` + +``` php +// Let the client specify the filename via header +$file = $uploader->receive(); // reads from X-Filename or Content-Disposition +``` + +## Size Limits + +Size is checked in two stages: + +1. **Before reading**: If a `Content-Length` header is present and exceeds the limit, the upload is rejected immediately. +2. **During reading**: Bytes are counted as they stream in. If the limit is exceeded mid-stream, the partial file is deleted and an exception is thrown. + +``` php +$uploader->setMaxFileSize(100 * 1024 * 1024); // 100MB + +try { + $file = $uploader->receive('large-file.zip'); +} catch (FileException $e) { + // "File exceeds size limit." +} +``` + +## Stream Processors + +Process data as it streams in — useful for hash verification, encryption, or transformations: + +``` php +use Generator; +use WebFiori\File\StreamingUploader; + +$uploader = new StreamingUploader('/home/files/uploads'); +$checksum = null; + +$uploader->setStreamProcessor(function (Generator $chunks, string $destPath) use (&$checksum) { + $hash = hash_init('sha256'); + $dest = fopen($destPath, 'wb'); + + foreach ($chunks as $chunk) { + hash_update($hash, $chunk); + fwrite($dest, $chunk); + } + + fclose($dest); + $checksum = hash_final($hash); +}); + +$file = $uploader->receive('document.pdf'); +echo "SHA-256: $checksum"; +``` + +When no stream processor is set, the uploader writes directly using `FileStream::writeFromStream()`. + +## Callbacks + +``` php +$uploader->setOnBeforeUpload(function (array $fileInfo): bool { + // $fileInfo contains 'name' and 'upload-path' + if (isBlacklisted($fileInfo['name'])) { + return false; // reject + } + return true; +}); + +$uploader->setOnAfterUpload(function (UploadedFile $file): void { + // Trigger async processing, notify user, etc. + dispatch(new ProcessUploadedFile($file->getAbsolutePath())); +}); +``` + +## Frontend Example + +JavaScript client sending a file as raw binary: + +``` javascript +const fileInput = document.querySelector('input[type="file"]'); +const file = fileInput.files[0]; + +const response = await fetch('/api/upload', { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Filename': file.name + }, + body: file +}); + +const result = await response.json(); +console.log(result); +``` + +## Full API Example + +A complete web service that accepts streaming uploads: + +``` php +use WebFiori\File\Exceptions\FileException; +use WebFiori\File\StreamingUploader; + +// In your route handler or web service +$uploader = new StreamingUploader('/home/files/uploads', ['pdf', 'docx', 'xlsx']); +$uploader->setMaxFileSize(50 * 1024 * 1024); // 50MB + +$uploader->setOnAfterUpload(function ($file) { + // Log or queue processing +}); + +try { + $file = $uploader->receive(); // filename from headers + + http_response_code(201); + echo json_encode([ + 'name' => $file->getName(), + 'size' => filesize($file->getAbsolutePath()), + 'mime' => $file->getMIME(), + ]); +} catch (FileException $e) { + http_response_code(422); + echo json_encode(['error' => $e->getMessage()]); +} +``` + +## Related Topics + +* [Uploading Files](uploading-files.md) — Overview and `FileUploader` (multipart form uploads) +* [Resumable Uploads](resumable-uploads.md) — Chunked uploads with resume support +* [Web Services](web-services.md) — Create upload APIs diff --git a/ui-package.md b/ui-package.md index 9da888d..3c0a4a8 100644 --- a/ui-package.md +++ b/ui-package.md @@ -20,6 +20,8 @@ In this page: * [Loading Template File](#loading-template-file) * [Slots](#slots) * [Components](#components) +* [Using PHP Templates](#using-php-templates) +* [The `HtmlRenderer` Class](#the-htmlrenderer-class) ## Introduction @@ -359,16 +361,81 @@ The library has a set of pre-made components at which the developer can use to b Available components are: -* [`Anchor`](https://webfiori.com/docs/webfiori/ui/Anchor) -* [`CodeSnippet`](https://webfiori.com/docs/webfiori/ui/CodeSnippet) -* [`Input`](https://webfiori.com/docs/webfiori/ui/Input) -* [`Label`](https://webfiori.com/docs/webfiori/ui/Label) -* [`ListItem`](https://webfiori.com/docs/webfiori/ui/ListItem) -* [`OrderedList`](https://webfiori.com/docs/webfiori/ui/OrderedList) -* [`Paragraph`](https://webfiori.com/docs/webfiori/ui/Paragraph) -* [`TableCell`](https://webfiori.com/docs/webfiori/ui/TableCell) -* [`TableRow`](https://webfiori.com/docs/webfiori/ui/TableRow) -* [`UnorderedList`](https://webfiori.com/docs/webfiori/ui/UnorderedList) +* [`Anchor`](https://webfiori.com/docs/webfiori/ui/Anchor) — `` element +* [`Br`](https://webfiori.com/docs/webfiori/ui/Br) — `
` element +* [`CodeSnippet`](https://webfiori.com/docs/webfiori/ui/CodeSnippet) — `
` element for code display
+* [`HeadNode`](https://webfiori.com/docs/webfiori/ui/HeadNode) — `` element with helpers for meta, CSS, JS
+* [`HTMLList`](https://webfiori.com/docs/webfiori/ui/HTMLList) — Base class for ordered/unordered lists
+* [`HTMLTable`](https://webfiori.com/docs/webfiori/ui/HTMLTable) — `` element with row/column management
+* [`Input`](https://webfiori.com/docs/webfiori/ui/Input) — `` element
+* [`JsCode`](https://webfiori.com/docs/webfiori/ui/JsCode) — Inline `