Pārlūkot izejas kodu

Ai Fixes

AGENT_WORK
Daniel Covington pirms 1 nedēļas
vecāks
revīzija
8acedba1d0
20 mainītis faili ar 544 papildinājumiem un 144 dzēšanām
  1. +42
    -0
      .ai/SKILLS.md
  2. +86
    -11
      .ai/skills/database/SKILL.md
  3. +113
    -4
      .ai/skills/mvc/SKILL.md
  4. +69
    -4
      .ai/skills/security/SKILL.md
  5. +1
    -2
      .gitignore
  6. +5
    -2
      AGENTS.md
  7. +27
    -30
      app/Controllers/EmployeeController.php
  8. +6
    -0
      config/view.php
  9. +31
    -2
      core/App.php
  10. +4
    -2
      core/Controller.php
  11. +20
    -0
      core/Database.php
  12. +10
    -2
      core/Dispatcher.php
  13. +15
    -71
      core/MigrationManager.php
  14. +11
    -1
      core/Request.php
  15. +1
    -1
      core/Response.php
  16. +9
    -7
      core/Route.php
  17. +15
    -0
      core/Router.php
  18. +62
    -0
      core/Validator.php
  19. +13
    -2
      core/View.php
  20. +4
    -3
      public/index.php

+ 42
- 0
.ai/SKILLS.md Parādīt failu

@@ -27,6 +27,17 @@ Do not turn this into Laravel, Symfony, Slim, CakePHP, or another large framewor


--- ---


## Configuration Files

| File | Purpose |
|------|---------|
| `config/database.php` | PDO DSN, credentials, and PDO options |
| `config/view.php` | Views directory path and layout file path |

Add new config files to `config/` — never hardcode environment-specific paths in `core/`.

---

## Tech Stack ## Tech Stack


- PHP 8.2+ - PHP 8.2+
@@ -119,6 +130,37 @@ Run basic tests:
php tests/run.php php tests/run.php
``` ```


Run database migrations:

```bash
php scripts/migrate.php up
```

Roll back the last migration:

```bash
php scripts/migrate.php down
```

Check migration status:

```bash
php scripts/migrate.php status
```

Create a new migration file:

```bash
php scripts/migrate.php make <name>
```

Reset and re-run all migrations:

```bash
php scripts/migrate.php fresh
php scripts/migrate.php fresh --seed
```

--- ---


## Request Flow ## Request Flow


+ 86
- 11
.ai/skills/database/SKILL.md Parādīt failu

@@ -17,6 +17,37 @@ Preferred database access:


--- ---


## Database Class API

`Core\Database` wraps PDO and exposes these methods:

| Method | Returns | Description |
|--------|---------|-------------|
| `query(string $sql, array $params = [])` | `array` | Runs a SELECT and returns all rows as associative arrays |
| `first(string $sql, array $params = [])` | `?array` | Runs a SELECT and returns the first row, or `null` |
| `execute(string $sql, array $params = [])` | `bool` | Runs INSERT / UPDATE / DELETE |
| `lastInsertId()` | `string` | Returns the last auto-increment ID as a string — cast to `int` for integer PKs |
| `transaction(callable $fn)` | `mixed` | Runs `$fn($db)` inside a transaction; commits on success, rolls back and rethrows on failure |
| `pdo()` | `PDO` | Returns the raw PDO instance for advanced use |

`lastInsertId()` is only meaningful immediately after an `execute()` INSERT on the same connection. Calling it at any other point returns `"0"`.

Typical INSERT + ID retrieval in a repository:

```php
public function create(Employee $employee): int
{
$this->database->execute(
'INSERT INTO employees (first_name, email) VALUES (:first_name, :email)',
['first_name' => $employee->firstName, 'email' => $employee->email]
);

return (int) $this->database->lastInsertId();
}
```

---

## Database Access Rules ## Database Access Rules


Use PDO or a well-maintained database abstraction layer/ORM. Use PDO or a well-maintained database abstraction layer/ORM.
@@ -52,23 +83,17 @@ Rules:


## Transactions ## Transactions


Use transactions when multiple writes must succeed or fail together.

Example:
Use `Database::transaction()` when multiple writes must succeed or fail together. It begins a transaction, runs the callback, commits on success, and rolls back and rethrows on any `Throwable`.


```php ```php
$pdo->beginTransaction();

try {
$database->transaction(function (Database $db) use ($order): void {
$orders->create($order); $orders->create($order);
$auditLog->record('order.created', $order->id()); $auditLog->record('order.created', $order->id());
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
});
``` ```


Do not call `$database->pdo()->beginTransaction()` directly for transaction management — use `transaction()` instead. Reserve `pdo()` for driver-specific features that have no `Database` API equivalent.

--- ---


## Repository Rules ## Repository Rules
@@ -82,6 +107,53 @@ try {


--- ---


## Migration System

The migration system is made up of three files:

- `core/Migration.php` — abstract base class all migration files extend
- `core/MigrationManager.php` — runs, rolls back, and tracks applied migrations
- `core/helpers.php` — provides the `migration_manager()` helper that wires a `MigrationManager` to the app database and the `database/migrations/` path

The CLI entry point is `scripts/migrate.php`. Run it from the project root:

| Command | Description |
|---------|-------------|
| `php scripts/migrate.php up` | Run all pending migrations |
| `php scripts/migrate.php down [steps]` | Roll back the last N migrations (default: 1) |
| `php scripts/migrate.php status` | Show which migrations have run and when |
| `php scripts/migrate.php make <name>` | Scaffold a new timestamped migration file |
| `php scripts/migrate.php fresh` | Roll back everything and re-run from scratch |
| `php scripts/migrate.php fresh --seed` | Same as fresh, then run the employee seed |

Migration files live in `database/migrations/` and are named `YYYYMMDD_HHMMSS_<slug>.php`.

Each file must return a `Migration` instance:

```php
<?php

declare(strict_types=1);

use Core\Database;
use Core\Migration;

return new class extends Migration
{
public function up(Database $database): void
{
$database->execute('CREATE TABLE ...');
}

public function down(Database $database): void
{
$database->execute('DROP TABLE IF EXISTS ...');
}
};
```

---

## Migration Rules ## Migration Rules


- Keep migrations small and reversible when practical. - Keep migrations small and reversible when practical.
@@ -89,6 +161,9 @@ try {
- Do not mix schema changes with unrelated feature logic. - Do not mix schema changes with unrelated feature logic.
- Use project migration conventions before inventing new ones. - Use project migration conventions before inventing new ones.
- Support SQLite/MySQL/SQL Server differences explicitly when the project targets multiple engines. - Support SQLite/MySQL/SQL Server differences explicitly when the project targets multiple engines.
- Do not use database-specific SQL in framework or migration code. `INSERT OR IGNORE` (SQLite) and `INSERT IGNORE` (MySQL) are not portable — use a check-then-insert pattern instead.
- Migration records in the `migrations` table are permanent. A row means "this migration ran against this database." Deleting a migration file does not remove the record and does not allow the migration to be re-run. To intentionally re-run a migration, delete its row from the `migrations` table manually — this makes the action explicit.
- To reset completely, use `php scripts/migrate.php fresh`, which rolls back all migrations in reverse order and re-runs them from scratch.


--- ---




+ 113
- 4
.ai/skills/mvc/SKILL.md Parādīt failu

@@ -122,6 +122,42 @@ php tests/run.php


--- ---


## Service and Request Injection

`Core\App::resolveArgs()` injects constructor and action parameters by type. Any service registered via `$app->bind(SomeClass::class, $instance)` in `public/index.php` is automatically injected when an action declares a typed parameter of that class.

`Core\Request` is registered as a binding in `public/index.php` before dispatch, so controller actions can declare it as a parameter without calling `Request::capture()` manually:

```php
public function index(Request $request): Response
{
$search = $request->input('search', '');
// ...
}
```

Route segment parameters (e.g. `{id}`) are still resolved by name before the binding lookup.

To make a service injectable, register it once at bootstrap:

```php
// public/index.php
$app->bind(Database::class, database());
```

Then declare it as a typed parameter in any action:

```php
public function index(Database $db): Response
{
// $db is injected automatically
}
```

Do not call `Request::capture()` inside action bodies. Declare the parameter instead.

---

## Controller Rules ## Controller Rules


- Keep controllers thin. - Keep controllers thin.
@@ -129,6 +165,29 @@ php tests/run.php
- Do not put database query details directly in controllers when a repository or service is more appropriate. - Do not put database query details directly in controllers when a repository or service is more appropriate.
- Do not put template rendering logic inside business services. - Do not put template rendering logic inside business services.
- Return or produce a response through the framework’s response mechanism. - Return or produce a response through the framework’s response mechanism.
- Use `requirePost($request)` to guard POST-only actions. It returns `?Response` (null when the method is POST, a 405 Response otherwise). Always return it immediately if non-null:

```php
if ($guard = $this->requirePost($request)) {
return $guard;
}
```

- Verify CSRF **before** field validation on any state-changing action. CSRF failure is a security gate, not a form validation error. See the Security skill for the helper pattern.
- When a controller uses a repository across multiple methods, store it as a nullable property and lazy-initialize once — do not call `new Repository(database())` on every method call:

```php
private ?EmployeeRepository $employees = null;

private function employees(): EmployeeRepository
{
if ($this->employees === null) {
$this->employees = new EmployeeRepository(database());
}

return $this->employees;
}
```


--- ---


@@ -141,6 +200,21 @@ php tests/run.php


--- ---


## View Configuration

View paths are set in `config/view.php`:

```php
return [
'views_path' => __DIR__ . '/../app/Views',
'layout_path' => __DIR__ . '/../app/Views/layouts/app.php',
];
```

`core/View.php` reads this file lazily on first use and caches the result. To change where views or the layout live, edit `config/view.php` — do not edit `core/View.php`. This follows the same pattern as `config/database.php`.

---

## Templates and Views ## Templates and Views


Keep presentation separate from business logic. Keep presentation separate from business logic.
@@ -167,6 +241,37 @@ Plain PHP template example:


--- ---


## Router Methods

`Core\Router` exposes one method per HTTP verb:

| Method | HTTP verb |
|--------|-----------|
| `$router->get($path, $handler)` | GET |
| `$router->post($path, $handler)` | POST |
| `$router->put($path, $handler)` | PUT |
| `$router->patch($path, $handler)` | PATCH |
| `$router->delete($path, $handler)` | DELETE |
| `$router->add($method, $path, $handler)` | Any verb |

---

## Method Override for HTML Forms

HTML forms only support GET and POST. To route a form submission to a PUT, PATCH, or DELETE handler, add a hidden `_method` field:

```html
<form method="POST" action="/employees/42">
<?= csrf_field() ?>
<input type="hidden" name="_method" value="PUT">
<!-- fields -->
</form>
```

`Core\Request::method()` checks for this field (and the `X-HTTP-Method-Override` header from JavaScript clients) when the base method is POST, and returns the overridden verb. Only `PUT`, `PATCH`, and `DELETE` are accepted as override values — all others are ignored.

---

## HTTP and Web Application Rules ## HTTP and Web Application Rules


Rules: Rules:
@@ -179,12 +284,16 @@ Rules:
- Redirect after successful POST to avoid duplicate form submission. - Redirect after successful POST to avoid duplicate form submission.
- Do not trust headers such as `X-Forwarded-For` unless configured behind a trusted proxy. - Do not trust headers such as `X-Forwarded-For` unless configured behind a trusted proxy.


Example POST guard:
Example POST guard using the framework helper:


```php ```php
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit('Method Not Allowed');
public function store(Request $request): Response
{
if ($guard = $this->requirePost($request)) {
return $guard;
}

// POST-only logic here
} }
``` ```




+ 69
- 4
.ai/skills/security/SKILL.md Parādīt failu

@@ -27,15 +27,41 @@ Untrusted data includes:


## Input Validation ## Input Validation


Validate on input.
Validate on input. Use `Core\Validator` for field validation — it is a fluent chain that collects all errors before returning.

### Validator API

| Method | Description |
|--------|-------------|
| `required(field, value, message?)` | Fails if value is null or blank |
| `maxLength(field, value, max, message?)` | Fails if string length exceeds max |
| `minLength(field, value, min, message?)` | Fails if string length is below min |
| `numeric(field, value, message?)` | Fails if value is not numeric |
| `min(field, value, min, message?)` | Fails if numeric value is below min |
| `max(field, value, max, message?)` | Fails if numeric value exceeds max |
| `email(field, value, message?)` | Fails if non-empty value is not a valid email |
| `date(field, value, format?, message?)` | Fails if non-empty value does not match the date format (default `Y-m-d`) |
| `in(field, value, allowed[], message?)` | Fails if value is not in the allowed list (strict comparison) |
| `passes()` | Returns `true` when no errors were collected |
| `fails()` | Returns `true` when any errors were collected |
| `errors()` | Returns `array<string, list<string>>` of field errors |

`email()`, `date()`, `min()`, and `max()` skip empty or non-numeric values respectively — pair them with `required()` or `numeric()` when the field is mandatory.


Example: Example:


```php ```php
$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
$validator = new Validator();


if ($email === false || $email === null) {
throw new InvalidArgumentException('A valid email address is required.');
$validator
->required('email', $form['email'], 'Email is required.')
->maxLength('email', $form['email'], 255)
->email('email', $form['email'], 'Enter a valid email address.')
->required('start_date', $form['start_date'], 'Start date is required.')
->date('start_date', $form['start_date'], 'Y-m-d', 'Enter a valid start date.');

if ($validator->fails()) {
// $validator->errors() returns field => [messages] map
} }
``` ```


@@ -45,6 +71,7 @@ Rules:
- Validate server-side even when client-side validation exists. - Validate server-side even when client-side validation exists.
- Reject unexpected fields when appropriate. - Reject unexpected fields when appropriate.
- Normalize data intentionally, not accidentally. - Normalize data intentionally, not accidentally.
- Do not reimplement email or date validation inline in controllers — use the Validator methods.


--- ---


@@ -130,6 +157,44 @@ State-changing actions include:
- Email changes - Email changes
- Permission changes - Permission changes


When using `_method` override to tunnel PUT, PATCH, or DELETE through a POST form, always include a CSRF token. The override is only honoured for POST requests, and only for the values `PUT`, `PATCH`, and `DELETE` — all other values are rejected by the framework.

In MindVisionCode PHP, use the built-in helpers from `core/helpers.php`:

| Helper | Purpose |
|--------|---------|
| `csrf_token()` | Generates and persists the token in the session |
| `csrf_field()` | Outputs a hidden `<input>` carrying the token — use in every state-changing form |
| `verify_csrf_token(string $token)` | Returns `bool` — call before any business logic in POST actions |

**Always verify CSRF before field validation and business logic.** A token failure is a security event, not a form validation error. Use a dedicated private method that returns `?Response` and short-circuits the action:

```php
private function verifyCsrf(Request $request): ?Response
{
if (!verify_csrf_token((string) $request->input('_token', ''))) {
return new Response('Your session has expired. Please go back and try again.', 419);
}

return null;
}
```

Call it as the first thing in the action:

```php
public function store(): Response
{
$request = Request::capture();

if ($guard = $this->verifyCsrf($request)) {
return $guard;
}

// field validation and business logic follow
}
```

--- ---


## Serialization and Data Exchange ## Serialization and Data Exchange


+ 1
- 2
.gitignore Parādīt failu

@@ -16,5 +16,4 @@
/database/*.sqlite-journal /database/*.sqlite-journal
.phpunit.result.cache .phpunit.result.cache
Thumbs.db Thumbs.db
docker-compose.yml
Dockerfile


+ 5
- 2
AGENTS.md Parādīt failu

@@ -118,7 +118,7 @@ For simple questions, answer directly.


## Framework Change Policy ## Framework Change Policy


The framework core may be modified to add functionality or optimize existing code, but **never silently**. Any time an agent identifies a change to framework-level code (dispatcher, routing, base controller, base repository, migration runner, validation engine, autoloader, or any file under `framework/`), it must stop and present the following proposal to the user before writing a single line:
The framework core may be modified to add functionality or optimize existing code, but **never silently**. Any time an agent identifies a change to framework-level code (dispatcher, routing, base controller, base repository, migration runner, validation engine, autoloader, or any file under `core/`), it must stop and present the following proposal to the user before writing a single line:


```text ```text
FRAMEWORK CHANGE PROPOSAL FRAMEWORK CHANGE PROPOSAL
@@ -145,6 +145,9 @@ Benefits:
Alternatives Considered: Alternatives Considered:
Any application-level workarounds that were ruled out and why. Any application-level workarounds that were ruled out and why.


Ai Agent Skills Update:
- What skills need to be changed to support this framework-level change?

Awaiting your approval before proceeding. Reply YES to apply, NO to skip, or ask questions. Awaiting your approval before proceeding. Reply YES to apply, NO to skip, or ask questions.
``` ```


@@ -153,7 +156,7 @@ Awaiting your approval before proceeding. Reply YES to apply, NO to skip, or ask
- If the user says NO, document the limitation as a comment or note and continue with the best available application-level workaround. - If the user says NO, document the limitation as a comment or note and continue with the best available application-level workaround.
- Keep framework changes small and focused — one concern per change. - Keep framework changes small and focused — one concern per change.
- After approval, note the change in the commit message so the history is clear. - After approval, note the change in the commit message so the history is clear.
- Update the proper skill file so that the new process can be applied to all future changes in this repository.
--- ---


## Skill Feedback Rule ## Skill Feedback Rule


+ 27
- 30
app/Controllers/EmployeeController.php Parādīt failu

@@ -9,13 +9,15 @@ use App\Repositories\EmployeeRepository;
use App\ViewModels\EmployeeFormViewModel; use App\ViewModels\EmployeeFormViewModel;
use Core\Controller; use Core\Controller;
use Core\Request; use Core\Request;
use Core\Response;
use Core\Validator; use Core\Validator;


class EmployeeController extends Controller class EmployeeController extends Controller
{ {
public function index()
private ?EmployeeRepository $employees = null;

public function index(Request $request)
{ {
$request = Request::capture();
$viewModel = $this->buildViewModel((string) $request->input('search', '')); $viewModel = $this->buildViewModel((string) $request->input('search', ''));
$viewModel->saved = $request->input('saved') === '1'; $viewModel->saved = $request->input('saved') === '1';


@@ -25,11 +27,14 @@ class EmployeeController extends Controller
]); ]);
} }


public function store()
public function store(Request $request)
{ {
$request = Request::capture();
if ($guard = $this->verifyCsrf($request)) {
return $guard;
}

$form = $this->sanitizeFormData($request); $form = $this->sanitizeFormData($request);
$errors = $this->validateForm($form, $request);
$errors = $this->validateForm($form);


if (empty($errors) && $this->employees()->findByEmail($form['email']) !== null) { if (empty($errors) && $this->employees()->findByEmail($form['email']) !== null) {
$errors['email'][] = 'That email address is already in use.'; $errors['email'][] = 'That email address is already in use.';
@@ -81,9 +86,8 @@ class EmployeeController extends Controller
return $this->redirect('/employees'); return $this->redirect('/employees');
} }


public function summary()
public function summary(Request $request)
{ {
$request = Request::capture();
$viewModel = $this->buildViewModel((string) $request->input('search', '')); $viewModel = $this->buildViewModel((string) $request->input('search', ''));


return $this->fragment('employees.partials.summary', [ return $this->fragment('employees.partials.summary', [
@@ -91,9 +95,8 @@ class EmployeeController extends Controller
]); ]);
} }


public function data()
public function data(Request $request)
{ {
$request = Request::capture();
$search = trim((string) $request->input('search', '')); $search = trim((string) $request->input('search', ''));
$rows = $this->employees()->search($search); $rows = $this->employees()->search($search);


@@ -136,7 +139,7 @@ class EmployeeController extends Controller
* @param array<string, string> $form * @param array<string, string> $form
* @return array<string, list<string>> * @return array<string, list<string>>
*/ */
private function validateForm(array $form, Request $request): array
private function validateForm(array $form): array
{ {
$validator = new Validator(); $validator = new Validator();


@@ -147,39 +150,33 @@ class EmployeeController extends Controller
->maxLength('last_name', $form['last_name'], 100, 'Last name must be 100 characters or fewer.') ->maxLength('last_name', $form['last_name'], 100, 'Last name must be 100 characters or fewer.')
->required('email', $form['email'], 'Email is required.') ->required('email', $form['email'], 'Email is required.')
->maxLength('email', $form['email'], 255, 'Email must be 255 characters or fewer.') ->maxLength('email', $form['email'], 255, 'Email must be 255 characters or fewer.')
->email('email', $form['email'], 'Enter a valid email address.')
->required('department', $form['department'], 'Department is required.') ->required('department', $form['department'], 'Department is required.')
->maxLength('department', $form['department'], 100, 'Department must be 100 characters or fewer.') ->maxLength('department', $form['department'], 100, 'Department must be 100 characters or fewer.')
->required('job_title', $form['job_title'], 'Job title is required.') ->required('job_title', $form['job_title'], 'Job title is required.')
->maxLength('job_title', $form['job_title'], 150, 'Job title must be 150 characters or fewer.') ->maxLength('job_title', $form['job_title'], 150, 'Job title must be 150 characters or fewer.')
->required('start_date', $form['start_date'], 'Start date is required.');

$errors = $validator->errors();
->required('start_date', $form['start_date'], 'Start date is required.')
->date('start_date', $form['start_date'], 'Y-m-d', 'Enter a valid start date.');


if (!verify_csrf_token((string) $request->input('_token', ''))) {
$errors['_token'][] = 'Your form session expired. Please refresh the page and try again.';
}

if ($form['email'] !== '' && filter_var($form['email'], FILTER_VALIDATE_EMAIL) === false) {
$errors['email'][] = 'Enter a valid email address.';
}

if ($form['start_date'] !== '' && !$this->isValidDate($form['start_date'])) {
$errors['start_date'][] = 'Enter a valid start date.';
}

return $errors;
return $validator->errors();
} }


private function isValidDate(string $value): bool
private function verifyCsrf(Request $request): ?Response
{ {
$date = \DateTimeImmutable::createFromFormat('Y-m-d', $value);
if (!verify_csrf_token((string) $request->input('_token', ''))) {
return new Response('Your session has expired. Please go back and try again.', 419);
}


return $date !== false && $date->format('Y-m-d') === $value;
return null;
} }


private function employees(): EmployeeRepository private function employees(): EmployeeRepository
{ {
return new EmployeeRepository(database());
if ($this->employees === null) {
$this->employees = new EmployeeRepository(database());
}

return $this->employees;
} }


private function isHtmxRequest(Request $request): bool private function isHtmxRequest(Request $request): bool


+ 6
- 0
config/view.php Parādīt failu

@@ -0,0 +1,6 @@
<?php

return [
'views_path' => __DIR__ . '/../app/Views',
'layout_path' => __DIR__ . '/../app/Views/layouts/app.php',
];

+ 31
- 2
core/App.php Parādīt failu

@@ -39,7 +39,7 @@ class App
{ {
$reflection = new ReflectionFunction($handler); $reflection = new ReflectionFunction($handler);


return $reflection->invokeArgs(array_values($parameters));
return $reflection->invokeArgs($this->resolveArgs($reflection, $parameters));
} }


protected function callMethod(array $handler, array $parameters): mixed protected function callMethod(array $handler, array $parameters): mixed
@@ -52,6 +52,35 @@ class App


$reflection = new ReflectionMethod($class, $method); $reflection = new ReflectionMethod($class, $method);


return $reflection->invokeArgs($class, array_values($parameters));
return $reflection->invokeArgs($class, $this->resolveArgs($reflection, $parameters));
}

protected function resolveArgs(\ReflectionFunctionAbstract $reflection, array $parameters): array
{
$args = [];

foreach ($reflection->getParameters() as $param) {
$name = $param->getName();

if (array_key_exists($name, $parameters)) {
$args[] = $parameters[$name];
continue;
}

$type = $param->getType();

if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) {
$typeName = $type->getName();

if (array_key_exists($typeName, $this->bindings)) {
$args[] = $this->bindings[$typeName];
continue;
}
}

$args[] = $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null;
}

return $args;
} }
} }

+ 4
- 2
core/Controller.php Parādīt failu

@@ -26,10 +26,12 @@ abstract class Controller
return Response::json($data); return Response::json($data);
} }


protected function requirePost(Request $request): void
protected function requirePost(Request $request): ?Response
{ {
if ($request->method() !== 'POST') { if ($request->method() !== 'POST') {
throw new \Exception('This action requires POST.');
return new Response('Method Not Allowed.', 405);
} }

return null;
} }
} }

+ 20
- 0
core/Database.php Parādīt failu

@@ -46,4 +46,24 @@ class Database


return $statement->execute($parameters); return $statement->execute($parameters);
} }

public function lastInsertId(): string
{
return $this->pdo->lastInsertId();
}

public function transaction(callable $fn): mixed
{
$this->pdo->beginTransaction();

try {
$result = $fn($this);
$this->pdo->commit();

return $result;
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
} }

+ 10
- 2
core/Dispatcher.php Parādīt failu

@@ -10,11 +10,13 @@ class Dispatcher
{ {
protected Router $router; protected Router $router;
protected App $app; protected App $app;
protected bool $debug;


public function __construct(Router $router, App $app)
public function __construct(Router $router, App $app, bool $debug = false)
{ {
$this->router = $router; $this->router = $router;
$this->app = $app; $this->app = $app;
$this->debug = $debug;
} }


public function dispatch(Request $request): Response public function dispatch(Request $request): Response
@@ -32,7 +34,13 @@ class Dispatcher


return $this->normalizeResponse($result); return $this->normalizeResponse($result);
} catch (Throwable $e) { } catch (Throwable $e) {
return Response::serverError($e->getMessage());
if (!$this->debug) {
error_log($e->getMessage());
}

$message = $this->debug ? $e->getMessage() : 'An unexpected error occurred.';

return Response::serverError($message);
} finally { } finally {
Request::clearCurrent(); Request::clearCurrent();
} }


+ 15
- 71
core/MigrationManager.php Parādīt failu

@@ -21,39 +21,9 @@ class MigrationManager
'CREATE TABLE IF NOT EXISTS migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, migration VARCHAR(255) NOT NULL, ran_at DATETIME DEFAULT CURRENT_TIMESTAMP)' 'CREATE TABLE IF NOT EXISTS migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, migration VARCHAR(255) NOT NULL, ran_at DATETIME DEFAULT CURRENT_TIMESTAMP)'
); );


$this->database->execute(
'DELETE FROM migrations
WHERE id NOT IN (
SELECT MIN(id)
FROM migrations
GROUP BY migration
)'
);

$this->database->execute( $this->database->execute(
'CREATE UNIQUE INDEX IF NOT EXISTS idx_migrations_migration_unique ON migrations (migration)' 'CREATE UNIQUE INDEX IF NOT EXISTS idx_migrations_migration_unique ON migrations (migration)'
); );

$files = array_map('basename', $this->migrationFiles());

if ($files === []) {
$this->database->execute('DELETE FROM migrations');
return;
}

$placeholders = [];
$parameters = [];

foreach ($files as $index => $file) {
$placeholder = 'migration_' . $index;
$placeholders[] = ':' . $placeholder;
$parameters[$placeholder] = $file;
}

$this->database->execute(
'DELETE FROM migrations WHERE migration NOT IN (' . implode(', ', $placeholders) . ')',
$parameters
);
} }


public function status(): array public function status(): array
@@ -101,22 +71,15 @@ class MigrationManager


$migration = $this->loadMigration($file); $migration = $this->loadMigration($file);


$this->database->pdo()->beginTransaction();

try {
$migration->up($this->database);
$this->database->transaction(function (Database $db) use ($migration, $name, &$ranMigrations): void {
$migration->up($db);


$this->database->execute(
'INSERT OR IGNORE INTO migrations (migration) VALUES (:migration)',
['migration' => $name]
);
if ($db->first('SELECT id FROM migrations WHERE migration = :migration', ['migration' => $name]) === null) {
$db->execute('INSERT INTO migrations (migration) VALUES (:migration)', ['migration' => $name]);
}


$this->database->pdo()->commit();
$ranMigrations[] = $name; $ranMigrations[] = $name;
} catch (\Throwable $exception) {
$this->database->pdo()->rollBack();
throw $exception;
}
});
} }


return $ranMigrations; return $ranMigrations;
@@ -128,11 +91,8 @@ class MigrationManager


$steps = max(1, $steps); $steps = max(1, $steps);
$applied = $this->database->query( $applied = $this->database->query(
"SELECT MAX(id) AS id, migration
FROM migrations
GROUP BY migration
ORDER BY id DESC
LIMIT {$steps}"
'SELECT id, migration FROM migrations ORDER BY id DESC LIMIT :steps',
['steps' => $steps]
); );
$rolledBack = []; $rolledBack = [];


@@ -144,22 +104,12 @@ class MigrationManager
} }


$migration = $this->loadMigration($file); $migration = $this->loadMigration($file);
$this->database->pdo()->beginTransaction();


try {
$migration->down($this->database);

$this->database->execute(
'DELETE FROM migrations WHERE id = :id',
['id' => $row['id']]
);

$this->database->pdo()->commit();
$this->database->transaction(function (Database $db) use ($migration, $row, &$rolledBack): void {
$migration->down($db);
$db->execute('DELETE FROM migrations WHERE id = :id', ['id' => $row['id']]);
$rolledBack[] = $row['migration']; $rolledBack[] = $row['migration'];
} catch (\Throwable $exception) {
$this->database->pdo()->rollBack();
throw $exception;
}
});
} }


return $rolledBack; return $rolledBack;
@@ -176,16 +126,10 @@ class MigrationManager
$migration = $this->loadMigration($file); $migration = $this->loadMigration($file);
$name = basename($file); $name = basename($file);


$this->database->pdo()->beginTransaction();

try {
$migration->down($this->database);
$this->database->pdo()->commit();
$this->database->transaction(function (Database $db) use ($migration, $name, &$rolledBack): void {
$migration->down($db);
$rolledBack[] = $name; $rolledBack[] = $name;
} catch (\Throwable $exception) {
$this->database->pdo()->rollBack();
throw $exception;
}
});
} }


$this->database->execute('DELETE FROM migrations'); $this->database->execute('DELETE FROM migrations');


+ 11
- 1
core/Request.php Parādīt failu

@@ -43,7 +43,17 @@ class Request


public function method(): string public function method(): string
{ {
return strtoupper($this->method);
$method = strtoupper($this->method);

if ($method === 'POST') {
$override = strtoupper((string) ($this->post['_method'] ?? $this->server['HTTP_X_HTTP_METHOD_OVERRIDE'] ?? ''));

if (in_array($override, ['PUT', 'PATCH', 'DELETE'], true)) {
return $override;
}
}

return $method;
} }


public function path(): string public function path(): string


+ 1
- 1
core/Response.php Parādīt failu

@@ -20,7 +20,7 @@ class Response
public static function json(array $data, int $status = 200): self public static function json(array $data, int $status = 200): self
{ {
return new self( return new self(
json_encode($data, JSON_PRETTY_PRINT),
json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
$status, $status,
['Content-Type' => 'application/json'] ['Content-Type' => 'application/json']
); );


+ 9
- 7
core/Route.php Parādīt failu

@@ -10,12 +10,19 @@ class Route
protected string $path; protected string $path;
protected mixed $handler; protected mixed $handler;
protected array $parameters = []; protected array $parameters = [];
protected string $compiledPattern;
protected array $parameterNames = [];


public function __construct(string $method, string $path, mixed $handler) public function __construct(string $method, string $path, mixed $handler)
{ {
$this->method = strtoupper($method); $this->method = strtoupper($method);
$this->path = $path; $this->path = $path;
$this->handler = $handler; $this->handler = $handler;

preg_match_all('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', $path, $names);
$this->parameterNames = $names[1];
$compiled = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '([^/]+)', $path);
$this->compiledPattern = '#^' . $compiled . '$#';
} }


public function matches(string $method, string $path): bool public function matches(string $method, string $path): bool
@@ -24,19 +31,14 @@ class Route
return false; return false;
} }


$routePattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '([^/]+)', $this->path);
$routePattern = '#^' . $routePattern . '$#';

if (!preg_match($routePattern, $path, $matches)) {
if (!preg_match($this->compiledPattern, $path, $matches)) {
return false; return false;
} }


array_shift($matches); array_shift($matches);
preg_match_all('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', $this->path, $names);

$this->parameters = []; $this->parameters = [];


foreach ($names[1] as $index => $name) {
foreach ($this->parameterNames as $index => $name) {
$this->parameters[$name] = $matches[$index] ?? null; $this->parameters[$name] = $matches[$index] ?? null;
} }




+ 15
- 0
core/Router.php Parādīt failu

@@ -18,6 +18,21 @@ class Router
return $this->add('POST', $path, $handler); return $this->add('POST', $path, $handler);
} }


public function put(string $path, callable|array|string $handler): Route
{
return $this->add('PUT', $path, $handler);
}

public function patch(string $path, callable|array|string $handler): Route
{
return $this->add('PATCH', $path, $handler);
}

public function delete(string $path, callable|array|string $handler): Route
{
return $this->add('DELETE', $path, $handler);
}

public function add(string $method, string $path, callable|array|string $handler): Route public function add(string $method, string $path, callable|array|string $handler): Route
{ {
$route = new Route($method, $path, $handler); $route = new Route($method, $path, $handler);


+ 62
- 0
core/Validator.php Parādīt failu

@@ -35,6 +35,68 @@ class Validator
return $this; return $this;
} }


public function email(string $field, mixed $value, string $message = ''): self
{
if ((string) $value !== '' && filter_var($value, FILTER_VALIDATE_EMAIL) === false) {
$this->errors[$field][] = $message ?: "{$field} must be a valid email address.";
}

return $this;
}

public function date(string $field, mixed $value, string $format = 'Y-m-d', string $message = ''): self
{
$str = (string) $value;

if ($str === '') {
return $this;
}

$parsed = \DateTimeImmutable::createFromFormat($format, $str);

if ($parsed === false || $parsed->format($format) !== $str) {
$this->errors[$field][] = $message ?: "{$field} must be a valid date ({$format}).";
}

return $this;
}

public function minLength(string $field, mixed $value, int $min, string $message = ''): self
{
if (strlen((string) $value) < $min) {
$this->errors[$field][] = $message ?: "{$field} must be at least {$min} characters.";
}

return $this;
}

public function min(string $field, mixed $value, int|float $min, string $message = ''): self
{
if (!is_numeric($value) || (float) $value < $min) {
$this->errors[$field][] = $message ?: "{$field} must be at least {$min}.";
}

return $this;
}

public function max(string $field, mixed $value, int|float $max, string $message = ''): self
{
if (!is_numeric($value) || (float) $value > $max) {
$this->errors[$field][] = $message ?: "{$field} must be no more than {$max}.";
}

return $this;
}

public function in(string $field, mixed $value, array $allowed, string $message = ''): self
{
if (!in_array($value, $allowed, true)) {
$this->errors[$field][] = $message ?: "{$field} must be one of: " . implode(', ', $allowed) . '.';
}

return $this;
}

public function passes(): bool public function passes(): bool
{ {
return empty($this->errors); return empty($this->errors);


+ 13
- 2
core/View.php Parādīt failu

@@ -9,7 +9,7 @@ class View
public static function render(string $view, array $data = []): Response public static function render(string $view, array $data = []): Response
{ {
$content = self::renderContent($view, $data); $content = self::renderContent($view, $data);
$layoutPath = __DIR__ . '/../app/Views/layouts/app.php';
$layoutPath = self::config()['layout_path'];


if (!file_exists($layoutPath)) { if (!file_exists($layoutPath)) {
return new Response($content); return new Response($content);
@@ -33,7 +33,7 @@ class View


private static function renderContent(string $view, array $data): string private static function renderContent(string $view, array $data): string
{ {
$path = __DIR__ . '/../app/Views/' . str_replace('.', '/', $view) . '.php';
$path = self::config()['views_path'] . '/' . str_replace('.', '/', $view) . '.php';


if (!file_exists($path)) { if (!file_exists($path)) {
throw new \Exception("View not found: {$view}"); throw new \Exception("View not found: {$view}");
@@ -47,6 +47,17 @@ class View
return (string) ob_get_clean(); return (string) ob_get_clean();
} }


private static function config(): array
{
static $config = null;

if ($config === null) {
$config = require __DIR__ . '/../config/view.php';
}

return $config;
}

private static function resolvePageTitle(array $data): string private static function resolvePageTitle(array $data): string
{ {
if (isset($data['pageTitle']) && is_string($data['pageTitle']) && trim($data['pageTitle']) !== '') { if (isset($data['pageTitle']) && is_string($data['pageTitle']) && trim($data['pageTitle']) !== '') {


+ 4
- 3
public/index.php Parādīt failu

@@ -4,20 +4,21 @@ declare(strict_types=1);


require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';


use Core\App;
use Core\Dispatcher; use Core\Dispatcher;
use Core\Request; use Core\Request;
use Core\Router; use Core\Router;


ensureSessionStarted(); ensureSessionStarted();


$app = new App();
$app = app();
$router = new Router(); $router = new Router();


require_once __DIR__ . '/../routes/web.php'; require_once __DIR__ . '/../routes/web.php';


$dispatcher = new Dispatcher($router, $app);
$debug = filter_var(getenv('APP_DEBUG'), FILTER_VALIDATE_BOOLEAN);
$dispatcher = new Dispatcher($router, $app, $debug);
$request = Request::capture(); $request = Request::capture();
$app->bind(Request::class, $request);
$response = $dispatcher->dispatch($request); $response = $dispatcher->dispatch($request);


$response->send(); $response->send();

Notiek ielāde…
Atcelt
Saglabāt

Powered by TurnKey Linux.