Compare commits

55 Commits
Author SHA1 Message Date
kj 3b6da45b01 refactor(validator): rename enum rule and method to in 2026-09-14 16:48:02 -03:00
kj b7260b5199 refactor(Router): use string callable syntax for default callbacks 2026-09-08 12:08:32 -03:00
kj 1f29622914 refactor(router): remove exit statement from redirect method 2026-09-07 20:00:15 -03:00
kj eb882db0c8 sync: 2026-09-07
70b1016 refactor(autoload): remove global exception handler
a534613 refactor(request): propagate validation failures as exceptions
5f3fc16 feat(router): add error boundary with exception callback
9dbfd7d feat: add named parameter injection to resolve()
2026-09-07 18:00:18 -03:00
kj 9ba43e3f25 sync: 2026-09-05
25db49f fix(model): reset query state when database query fails
9cb41d5 fix(model): use RANDOM() for non-MySQL databases in orderBy
2026-09-05 16:49:10 -03:00
kj 54e96ba4bb sync: first sync with the new develop branch
a4611e8 chore(src): rename .keep files to .gitkeep
2026-09-05 14:21:52 -03:00
kj 9027f36748 refactor(model): use reflection for search field discovery 2026-09-05 00:39:16 -03:00
kj 2ae9ef39f3 feat(request): negotiate error response frmat based on Accept header 2026-09-03 18:23:42 -03:00
kj ee0db0307e docs(Validator): Expand documentation with complete rules reference 2026-09-03 18:22:52 -03:00
kj 44cbe6bbdc feat(validator): add file upload validation rules 2026-09-03 18:07:54 -03:00
kj 31728af351 docs: Clarify phpdoc 2026-09-03 16:12:54 -03:00
kj 4845d14ef5 chore(validator): suppress PHPCS rule for non-camel-case method 2026-09-03 15:59:48 -03:00
kj aca4e732ed feat(validation): Add human-readable validation error messages 2026-09-03 15:59:36 -03:00
kj f475c9125f feat(Validator): Add confirmed, required_with, and required_if rules 2026-09-03 15:03:28 -03:00
kj 4fe7314389 feat(Validator): Add regex and date validation methods 2026-09-03 14:42:15 -03:00
kj 1f366830bc feat(validation): add type and size-based validation methods
Add string, array, min, max, between, and size validators to the
Validator class. A private measure helper centralizes size semantics
for numbers (value), strings (length), and arrays (element count).
2026-09-03 14:35:02 -03:00
kj 15df7c8d96 refactor(Validator): improve rule argument parsing 2026-09-03 14:20:02 -03:00
kj b8dd1fd0f6 refactor(model): remove default 'id' from dbIgnoreSave list 2026-06-19 16:08:29 -03:00
kj 83c80bd4ab fix(model): prevent overwriting primary key on save 2026-06-19 15:34:47 -03:00
kj b13c07d3c4 fix(model): Remove the NULL check in the attribute loop 2026-06-19 15:29:51 -03:00
kj b95747bfe0 feat(model): add type casting for DateTime 2026-06-19 15:14:25 -03:00
kj 01c5eceeb4 refactor(model): Use match expression for PDO type detection 2026-06-19 14:52:25 -03:00
kj ee9c109ed9 fix(model): improve PDO parameter binding and update logic 2026-06-19 14:47:01 -03:00
kj 7eed69725d refactor(model): extract type casting logic into helper methods 2026-06-19 14:46:30 -03:00
kj e37035eed8 fix(model): support both backed and unit enums 2026-06-13 16:48:07 -03:00
kj 5312e5060e fix(model): reset dbQueryVariables before update and insert
Clear the static $dbQueryVariables array before performing update
and insert operations to prevent data leakage between queries.
2026-06-13 15:30:45 -03:00
kj 3c9db3ac53 refactor(neuron): remove return type hint from __get method 2026-06-05 13:28:19 -03:00
outcontrol 2b5d19ecd5 fix(synapsis): Skip variadic parameters in argument resolution 2026-04-15 16:35:32 -03:00
outcontrol 53e862d126 feat(exception): Register global exception handler
This handler catches uncaught exceptions and prints their message
and stack trace.
2026-03-16 06:38:26 -03:00
outcontrol b6baeef32f style(Synapsis): Wrap long exception message for readability 2026-03-16 06:25:09 -03:00
outcontrol 159da01d5c fix(synapsis): Include parameter name in untyped parameter error 2026-03-16 06:24:16 -03:00
outcontrol 3770dd6d9b refactor(model): Track model persistence state 2026-03-11 18:48:32 -03:00
outcontrol 76a69e5ef8 refactor(Model): Standarize property names 2026-03-11 18:22:16 -03:00
kj c5a4967ae4 refactor: Extract autoloader to dedicated file 2026-02-10 14:09:13 -03:00
kj c0b9f3851a chore(router): Remove redundant middleware count check 2026-02-02 10:23:48 -03:00
kj 78e199e300 feat(router): Introduce withMiddleware() for scoped middlewares 2026-02-01 08:25:54 -03:00
kj 8cd5e114dd refactor(router): Align type hint for optional parameter in docblock 2026-02-01 07:47:04 -03:00
kj ce03bdb27d fix(model): Fix postgresql search. 2025-10-27 15:16:54 -03:00
kj b24598b118 fix(sqlite): Correct base table for RIGHT JOIN conversion 2025-10-27 15:15:45 -03:00
kj 5019b89cc0 docs(readme): Update PostgreSQL compatibility status 2025-10-27 10:27:18 -03:00
kj d030e8d30e refactor(model)!: Allow groupBy to accept multiple args instead an array 2025-10-27 10:26:12 -03:00
kj b2672ad92f fix(model): Remove ON clause from cross join 2025-10-27 10:24:25 -03:00
kj 4c57770b43 refactor(model): Remove backticks from column names 2025-10-27 10:23:55 -03:00
kj 8e84450f95 fix(query): Adjust LIMIT OFFSET clause syntax 2025-10-27 10:23:31 -03:00
kj ece32b9bbe docs(readme): Update supported where clauses 2025-10-27 06:50:13 -03:00
kj a5cf9d239d refactor(model): Fix typo on WHERE EXISTS/NOT EXISTS methods 2025-10-27 06:46:17 -03:00
kj b9509c49ed refactor(model): Add string type hint to select columns 2025-10-21 16:26:39 -03:00
kj 6e433b4d06 feat(query-builder): Implement advanced WHERE and new JOIN clauses 2025-10-17 13:13:01 -03:00
kj 892b3614ec refactor(db): Detect DB type using PDO driver name 2025-10-17 13:12:02 -03:00
kj a0b544eae5 refactor(Model): Rename bindValue to bind and make public 2025-10-17 11:55:37 -03:00
kj 20fd78ab53 docs(Request): Remove param comment 2025-10-16 20:51:52 -03:00
kj f5411daaa5 Translate comments to English 2025-10-16 20:02:54 -03:00
kj 7e7ec68fd7 refactor(router): Use Neuron object for route parameters 2025-10-16 12:04:05 -03:00
kj ac9a661bc0 fix(htaccess): Block direct access to vendor and src folders 2025-10-11 12:15:15 -03:00
kj c8d7b69367 feat(bootstrap): Introduce Libs\Loader for directory loading 2025-10-11 10:44:20 -03:00
17 changed files with 1788 additions and 373 deletions
+3
View File
@@ -1,6 +1,9 @@
<IfModule mod_rewrite.c> <IfModule mod_rewrite.c>
RewriteEngine On RewriteEngine On
# Bloquear acceso a las carpetas vendor/ y src/
RewriteRule ^(vendor|src)/.*$ - [F,L]
# Handle Front Controller... # Handle Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
+14
View File
@@ -0,0 +1,14 @@
<?php
require_once('config.php');
// Autoloader for the core classes
spl_autoload_register(function ($className) {
$fp = str_replace('\\', '/', $className);
$name = basename($fp);
$dir = dirname($fp);
$file = ROOT_CORE . '/' . $dir . '/' . $name . '.php';
if (file_exists($file)) {
require_once $file;
}
});
+3 -3
View File
@@ -1,15 +1,15 @@
<?php <?php
// Configuración de la base de datos // Database configuration
define('DB_TYPE', 'mysql'); define('DB_TYPE', 'mysql');
define('DB_HOST', 'localhost'); define('DB_HOST', 'localhost');
define('DB_NAME', ''); define('DB_NAME', '');
define('DB_USER', ''); define('DB_USER', '');
define('DB_PASS', ''); define('DB_PASS', '');
// Configuración del sitio // Site configuration
define('SITE_URL', ''); define('SITE_URL', '');
// Configuración avanzada // Advanced configuration
define('ROOT_DIR', __DIR__); define('ROOT_DIR', __DIR__);
define('ROOT_CORE', ROOT_DIR . '/src'); define('ROOT_CORE', ROOT_DIR . '/src');
+9 -17
View File
@@ -1,23 +1,15 @@
<?php <?php
require_once('config.php'); use Libs\Loader;
use Libs\Router;
// Incluir clases require_once('autoload.php');
spl_autoload_register(function ($className) {
$fp = str_replace('\\', '/', $className);
$name = basename($fp);
$dir = dirname($fp);
$file = ROOT_CORE . '/' . $dir . '/' . $name . '.php';
if (file_exists($file)) {
require_once $file;
}
});
// Incluir routers // Router autoload
$routers = glob(ROOT_CORE . '/Routers/*.php'); Loader::load(ROOT_CORE . '/Routers/');
foreach ($routers as $file) { // Other autoloads
require_once($file); Loader::load(ROOT_CORE . '/Autoloads/');
}
\Libs\Router::apply(); // Run the routers
Router::apply();
+83 -71
View File
@@ -28,79 +28,91 @@ En la siguiente tabla se encuentra la lista de estados de los gestores de bases
+ *En blanco* como que no ha sido probado aún. + *En blanco* como que no ha sido probado aún.
+ *error* como que fue probado, no funciona y no ha sido aún arreglado. + *error* como que fue probado, no funciona y no ha sido aún arreglado.
+ *not supported* como no soportado por el gestor de bases de datos. + *not supported* como no soportado por el gestor de bases de datos.
+ *fixed* para aquello que no existe en el gestor de DB, pero la librería lo traduce a un equivalente. + *fixed* para aquello que no existe en el gestor de BD, pero la librería lo traduce a un equivalente.
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| method | MySQL/MariaDB | sqlite3 | postgreSQL | | method | MySQL/MariaDB | sqlite3 | postgreSQL |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| db | ok | ok | | | db | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| query | ok | ok | | | query | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| resetQuery | ok | ok | | | resetQuery | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| buildQuery | ok | ok | | | buildQuery | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| getInstance | ok | ok | | | getInstance | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| getVars | ok | ok | | | getVars | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| className | ok | ok | | | className | ok | ok | pk |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| table | ok | ok | | | table | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| update | ok | ok | | | update | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| beginTransaction | ok | ok | | | beginTransaction | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| rollBack | ok | ok | | | rollBack | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| commit | ok | ok | | | commit | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| add | ok | ok | | | add | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| save | ok | ok | | | save | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| delete | ok | ok | | | delete | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| select | ok | ok | | | select | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| from | ok | ok | | | from | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| where | ok | ok | | | where | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| where_in | ok | ok | | | whereIn | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| leftJoin | ok | ok | | | whereNotIn | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| rightJoin | ok | not supported | | | whereNull | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| innerJoin | ok | ok | | | whereNotNull | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| and | ok | ok | | | whereExists | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| or | ok | ok | | | whereNotExists | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| groupBy | ok | ok | | | leftJoin | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| limit | ok | ok | | | rightJoin | ok | fixed | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| orderBy | ok | ok | | | innerJoin | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| count | ok | ok | | | crossJoin | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| getById | ok | ok | | | and | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| search | ok | ok | | | or | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| get | ok | ok | | | groupBy | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| getFirst | ok | ok | | | limit | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| all | ok | ok | | | orderBy | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| setNull | ok | ok | | | count | ok | ok | ok |
|------------------+---------------+---------------+------------| |------------------+---------------+---------+------------|
| getById | ok | ok | ok |
|------------------+---------------+---------+------------|
| search | ok | ok | ok |
|------------------+---------------+---------+------------|
| get | ok | ok | ok |
|------------------+---------------+---------+------------|
| getFirst | ok | ok | ok |
|------------------+---------------+---------+------------|
| all | ok | ok | ok |
|------------------+---------------+---------+------------|
| setNull | ok | ok | ok |
|------------------+---------------+---------+------------|
* Contacto * Contacto
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace Libs;
/**
* Loader - DuckBrain
*
* Simple library to bulk load multiple php files inside a folder.
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
class Loader
{
/**
* Loads all PHP files from a specified directory.
* If the directory does not exist or is not a directory, no files will be loaded.
*
* @param string $directoryPath The path to the directory containing the PHP files to load.
* @return void
*/
public static function load(string $directoryPath): void
{
if (is_dir($directoryPath)) {
foreach (glob(rtrim($directoryPath, '/') . '/*.php') as $file) {
require_once($file);
}
}
}
}
+340 -163
View File
@@ -23,46 +23,47 @@ use ReflectionProperty;
#[AllowDynamicProperties] #[AllowDynamicProperties]
class Model class Model
{ {
/**
* @var array Attributes that should be set to NULL on update.
*/
protected array $toNull = [];
/** /**
* @var string The name of the primary key column. * @var string The name of the primary key column.
*/ */
protected static string $primaryKey = 'id'; protected static string $dbPrimaryKey = 'id';
/** /**
* @var array Attributes to ignore when saving to the database. * @var array Attributes to ignore when saving to the database.
*/ */
protected static array $ignoreSave = ['id']; protected static array $dbIgnoreSave = [];
/** /**
* @var array Attributes that should be explicitly saved, even if private/protected. * @var array Attributes that should be explicitly saved, even if private/protected.
*/ */
protected static array $forceSave = []; protected static array $dbForceSave = [];
/**
* @var bool Attribute that persis if is current model is saved into the database
*/
protected bool $dbIsSaved = false;
/** /**
* @var string The database table name. * @var string The database table name.
*/ */
protected static string $table; protected static string $dbTableName;
/** /**
* @var array Variables for PDO prepared statements. * @var array Variables for PDO prepared statements.
*/ */
protected static array $queryVars = []; protected static array $dbQueryVariables = [];
/** /**
* @var array Current SELECT query components. * @var array Current SELECT query components.
*/ */
protected static array $querySelect = [ protected static array $dbQuery = [
'select' => ['*'], 'select' => ['*'],
'where' => '', 'where' => '',
'from' => '', 'from' => '',
'leftJoin' => '', 'leftJoin' => '',
'rightJoin' => '', 'rightJoin' => '',
'innerJoin' => '', 'innerJoin' => '',
'crossJoin' => '',
'orderBy' => '', 'orderBy' => '',
'groupBy' => '', 'groupBy' => '',
'limit' => '', 'limit' => '',
@@ -155,13 +156,29 @@ class Model
try { try {
$prepared = $db->prepare($query); $prepared = $db->prepare($query);
$prepared->execute(static::$queryVars); foreach (static::$dbQueryVariables as $key => $value) {
// Detect data type to choose the PDO constant
$type = match (gettype($value)) {
'integer' => PDO::PARAM_INT,
'boolean' => PDO::PARAM_BOOL,
'NULL' => PDO::PARAM_NULL,
default => PDO::PARAM_STR,
};
$prepared->bindValue($key, $value, $type);
}
$prepared->execute();
} catch (PDOException $e) { } catch (PDOException $e) {
if ($db->inTransaction()) { if ($db->inTransaction()) {
$db->rollBack(); $db->rollBack();
} }
$vars = json_encode(static::$queryVars); $vars = json_encode(static::$dbQueryVariables);
if ($resetQuery) {
static::resetQuery();
}
throw new Exception( throw new Exception(
"\nError at query to database.\n" . "\nError at query to database.\n" .
@@ -186,18 +203,19 @@ class Model
*/ */
protected static function resetQuery(): void protected static function resetQuery(): void
{ {
static::$querySelect = [ static::$dbQuery = [
'select' => ['*'], 'select' => ['*'],
'where' => '', 'where' => '',
'from' => '', 'from' => '',
'leftJoin' => '', 'leftJoin' => '',
'rightJoin' => '', 'rightJoin' => '',
'innerJoin' => '', 'innerJoin' => '',
'crossJoin' => '',
'orderBy' => '', 'orderBy' => '',
'groupBy' => '', 'groupBy' => '',
'limit' => '', 'limit' => '',
]; ];
static::$queryVars = []; static::$dbQueryVariables = [];
} }
/** /**
@@ -209,40 +227,44 @@ class Model
*/ */
protected static function buildQuery(): string protected static function buildQuery(): string
{ {
$sql = 'SELECT ' . join(', ', static::$querySelect['select']); $sql = 'SELECT ' . join(', ', static::$dbQuery['select']);
if (static::$querySelect['from'] != '') { if (static::$dbQuery['from'] != '') {
$sql .= ' FROM ' . static::$querySelect['from']; $sql .= ' FROM ' . static::$dbQuery['from'];
} else { } else {
$sql .= ' FROM ' . static::table(); $sql .= ' FROM ' . static::table();
} }
if (static::$querySelect['innerJoin'] != '') { if (static::$dbQuery['crossJoin'] != '') {
$sql .= static::$querySelect['innerJoin']; $sql .= static::$dbQuery['crossJoin'];
} }
if (static::$querySelect['leftJoin'] != '') { if (static::$dbQuery['innerJoin'] != '') {
$sql .= static::$querySelect['leftJoin']; $sql .= static::$dbQuery['innerJoin'];
} }
if (static::$querySelect['rightJoin'] != '') { if (static::$dbQuery['leftJoin'] != '') {
$sql .= static::$querySelect['rightJoin']; $sql .= static::$dbQuery['leftJoin'];
} }
if (static::$querySelect['where'] != '') { if (static::$dbQuery['rightJoin'] != '') {
$sql .= ' WHERE ' . static::$querySelect['where']; $sql .= static::$dbQuery['rightJoin'];
} }
if (static::$querySelect['groupBy'] != '') { if (static::$dbQuery['where'] != '') {
$sql .= ' GROUP BY ' . static::$querySelect['groupBy']; $sql .= ' WHERE ' . static::$dbQuery['where'];
} }
if (static::$querySelect['orderBy'] != '') { if (static::$dbQuery['groupBy'] != '') {
$sql .= ' ORDER BY ' . static::$querySelect['orderBy']; $sql .= ' GROUP BY ' . static::$dbQuery['groupBy'];
} }
if (static::$querySelect['limit'] != '') { if (static::$dbQuery['orderBy'] != '') {
$sql .= ' LIMIT ' . static::$querySelect['limit']; $sql .= ' ORDER BY ' . static::$dbQuery['orderBy'];
}
if (static::$dbQuery['limit'] != '') {
$sql .= ' LIMIT ' . static::$dbQuery['limit'];
} }
return $sql; return $sql;
@@ -259,10 +281,10 @@ class Model
* @return string * @return string
* Substitution parameter. * Substitution parameter.
*/ */
private static function bindValue(string $value): string public static function bind(string $value): string
{ {
$index = ':v_' . count(static::$queryVars); $index = ':v_' . count(static::$dbQueryVariables);
static::$queryVars[$index] = $value; static::$dbQueryVariables[$index] = $value;
return $index; return $index;
} }
@@ -282,26 +304,61 @@ class Model
$instance = new $class(); $instance = new $class();
$reflection = new ReflectionClass($instance); $reflection = new ReflectionClass($instance);
$properties = $reflection->getProperties(); $properties = $reflection->getProperties();
$propertyNames = array_map(function ($property) {
return static::camelCaseToSnakeCase($property->name); $map = [];
}, $properties); foreach ($properties as $prop) {
$map[static::camelCaseToSnakeCase($prop->name)] = $prop;
}
foreach ($elem as $key => $value) { foreach ($elem as $key => $value) {
$index = array_search($key, $propertyNames); if (isset($map[$key])) {
if (is_numeric($index)) { $property = $map[$key];
if (enum_exists($properties[$index]->getType()->getName())) { $instance->{$property->name} = static::castValue($property, $value);
$instance->{$properties[$index]->name} = $properties[$index]->getType()->getName()::tryfrom($value);
} else {
$instance->{$properties[$index]->name} = $value;
}
} else { } else {
$instance->{static::snakeCaseToCamelCase($key)} = $value; $instance->{static::snakeCaseToCamelCase($key)} = $value;
} }
} }
$instance->markAsSaved();
return $instance; return $instance;
} }
/**
* Handles type casting for properties
*
* @param ReflectionProperty $property
* @param mixed $value
*
* @return mixed
*/
protected static function castValue(ReflectionProperty $property, mixed $value): mixed
{
$type = $property->getType();
if (!$type) {
return $value;
}
$typeName = $type->getName();
// Enum cast
if (enum_exists($typeName)) {
if (method_exists($typeName, 'tryFrom')) {
return $typeName::tryFrom($value);
}
if (in_array($value, array_column($typeName::cases(), 'name'))) {
return $typeName::{$value};
}
}
// DateTime cast
if (is_subclass_of($typeName, \DateTimeInterface::class)) {
return $value ? new $typeName($value) : null;
}
return $value;
}
/** /**
* Returns the attributes to be saved for the current class. * Returns the attributes to be saved for the current class.
* Attributes will be those that are public and not excluded in static::$ignoreSave, * Attributes will be those that are public and not excluded in static::$ignoreSave,
@@ -317,30 +374,43 @@ class Model
$result = []; $result = [];
foreach ($properties as $property) { foreach ($properties as $property) {
if (!in_array($property->name, static::$ignoreSave)) { // Avoid save unset variables
$result[$this->camelCaseToSnakeCase($property->name)] = isset($this->{$property->name}) if ($property->getType() && !$property->isInitialized($this)) {
? $this->{$property->name} : null; continue;
}
if (!in_array($property->name, static::$dbIgnoreSave)) {
$name = $this->camelCaseToSnakeCase($property->name);
$result[$name] = $this->castToDbValue($this->{$property->name});
} }
} }
foreach (static::$forceSave as $value) { foreach (static::$dbForceSave as $value) {
$result[$value] = isset($this->$value) $result[$value] = $this->castToDbValue($this->$value);
? $this->$value : null;
}
foreach ($result as $i => $property) {
if (gettype($property) == 'boolean') {
$result[$i] = $property ? '1' : '0';
}
if ($property instanceof \UnitEnum) {
$result[$i] = $property->value ?? $property->name;
}
} }
return $result; return $result;
} }
/**
* Converts a PHP value to a database-compatible value.
*
* @param mixed $value
* @return mixed
*/
protected function castToDbValue(mixed $value): mixed
{
if ($value instanceof \UnitEnum) {
return $value->value ?? $value->name;
}
if ($value instanceof \DateTimeInterface) {
return $value->format('Y-m-d H:i:s');
}
return $value;
}
/** /**
* Returns the name of the current class, even if it's an extended class. * Returns the name of the current class, even if it's an extended class.
* *
@@ -364,8 +434,8 @@ class Model
*/ */
protected static function table(): string protected static function table(): string
{ {
if (isset(static::$table)) { if (isset(static::$dbTableName)) {
return static::$table; return static::$dbTableName;
} }
return static::camelCaseToSnakeCase(static::className()) . 's'; return static::camelCaseToSnakeCase(static::className()) . 's';
@@ -410,26 +480,20 @@ class Model
protected function update(): void protected function update(): void
{ {
$atts = $this->getVars(); $atts = $this->getVars();
static::$dbQueryVariables = [];
$set = [];
foreach ($atts as $key => $value) { foreach ($atts as $key => $value) {
if (isset($value)) {
if (in_array($key, $this->toNull)) {
$set[] = "$key=NULL";
} else {
$set[] = "$key=:$key"; $set[] = "$key=:$key";
static::$queryVars[':' . $key] = $value; static::$dbQueryVariables[':' . $key] = $value;
}
} else {
if (in_array($key, $this->toNull)) {
$set[] = "$key=NULL";
}
}
} }
$table = static::table(); $table = static::table();
$pk = static::$primaryKey; $pk = static::$dbPrimaryKey;
$pkv = $this->$pk; $pkv = $this->$pk;
$sql = "UPDATE $table SET " . join(', ', $set) . " WHERE $pk='$pkv'"; $sql = "UPDATE $table SET " . join(', ', $set) . " WHERE $pk=:$pk";
static::$dbQueryVariables[":$pk"] = $pkv;
static::query($sql); static::query($sql);
} }
@@ -444,38 +508,48 @@ class Model
$atts = $this->getVars(); $atts = $this->getVars();
$into = []; $into = [];
$values = []; $values = [];
static::$dbQueryVariables = [];
foreach ($atts as $key => $value) { foreach ($atts as $key => $value) {
if (isset($value)) { $into[] = "$key";
$into[] = "`$key`";
$values[] = ":$key"; $values[] = ":$key";
static::$queryVars[":$key"] = $value; static::$dbQueryVariables[":$key"] = $value;
}
} }
$table = static::table(); $table = static::table();
$sql = "INSERT INTO $table (" . join(', ', $into) . ") VALUES (" . join(', ', $values) . ")"; $sql = "INSERT INTO $table (" . join(', ', $into) . ") VALUES (" . join(', ', $values) . ")";
static::query($sql); static::query($sql);
$pk = static::$primaryKey; $pk = static::$dbPrimaryKey;
$this->$pk = $db->lastInsertId(); $this->$pk ??= $db->lastInsertId();
$this->markAsSaved();
} }
/** /**
* Checks if the object to be saved is new or not, and based on the result, * Checks if the object to be saved is new or not, and based on the result,
* calls `update` to update an existing row or `add` to insert a new row. * calls `update` to update an existing row or `add` to insert a new row.
*
* @return void * @return void
*/ */
public function save(): void public function save(): void
{ {
$pk = static::$primaryKey; if ($this->dbIsSaved) {
if (isset($this->$pk)) {
$this->update(); $this->update();
} else { } else {
$this->add(); $this->add();
} }
} }
/**
* Mark the current instance as already saved
*
* @return void
*/
public function markAsSaved(): void
{
$this->dbIsSaved = true;
}
/** /**
* Deletes the current object from the database. * Deletes the current object from the database.
* @return void * @return void
@@ -483,24 +557,25 @@ class Model
public function delete(): void public function delete(): void
{ {
$table = static::table(); $table = static::table();
$pk = static::$primaryKey; $pk = static::$dbPrimaryKey;
$sql = "DELETE FROM $table WHERE $pk=:$pk"; $sql = "DELETE FROM $table WHERE $pk=:$pk";
static::$queryVars[":$pk"] = $this->$pk; static::$dbQueryVariables[":$pk"] = $this->$pk;
static::query($sql); static::query($sql);
$this->dbIsSaved = false;
} }
/** /**
* Defines SELECT in the SQL statement. * Defines SELECT in the SQL statement.
* *
* @param array $columns * @param array<string> $columns
* Columns to be selected in the SQL query. * Columns to be selected in the SQL query.
* *
* @return static * @return static
*/ */
public static function select(...$columns): static public static function select(string ...$columns): static
{ {
static::$querySelect['select'] = $columns; static::$dbQuery['select'] = $columns;
return new static(); return new static();
} }
@@ -515,7 +590,7 @@ class Model
*/ */
public static function from(...$tables): static public static function from(...$tables): static
{ {
static::$querySelect['from'] = join(', ', $tables); static::$dbQuery['from'] = join(', ', $tables);
return new static(); return new static();
} }
@@ -582,13 +657,13 @@ class Model
} }
if (!$no_filter) { if (!$no_filter) {
$value = static::bindValue($value); $value = static::bind($value);
} }
if (static::$querySelect['where'] == '') { if (static::$dbQuery['where'] == '') {
static::$querySelect['where'] = "$column $operatorOrValue $value"; static::$dbQuery['where'] = "$column $operatorOrValue $value";
} else { } else {
static::$querySelect['where'] .= " AND $column $operatorOrValue $value"; static::$dbQuery['where'] .= " AND $column $operatorOrValue $value";
} }
return new static(); return new static();
@@ -624,13 +699,13 @@ class Model
} }
if (!$no_filter) { if (!$no_filter) {
$value = static::bindValue($value); $value = static::bind($value);
} }
if (static::$querySelect['where'] == '') { if (static::$dbQuery['where'] == '') {
static::$querySelect['where'] = "$column $operatorOrValue $value"; static::$dbQuery['where'] = "$column $operatorOrValue $value";
} else { } else {
static::$querySelect['where'] .= " OR $column $operatorOrValue $value"; static::$dbQuery['where'] .= " OR $column $operatorOrValue $value";
} }
return new static(); return new static();
@@ -645,36 +720,113 @@ class Model
* @param array $arr * @param array $arr
* Array with all values to compare against the column. * Array with all values to compare against the column.
* *
* @param bool $in
* Defines whether to check positively (IN) or negatively (NOT IN).
*
* @return static * @return static
*/ */
public static function whereIn( public static function whereIn(
string $column, string $column,
array $arr, array $arr,
bool $in = true
): static { ): static {
$arrIn = []; $arrIn = [];
foreach ($arr as $value) { foreach ($arr as $value) {
$arrIn[] = static::bindValue($value); $arrIn[] = static::bind($value);
} }
if ($in) { $where = "$column IN (" . join(', ', $arrIn) . ")";
$where_in = "$column IN (" . join(', ', $arrIn) . ")"; if (static::$dbQuery['where'] == '') {
static::$dbQuery['where'] = $where;
} else { } else {
$where_in = "$column NOT IN (" . join(', ', $arrIn) . ")"; static::$dbQuery['where'] .= " AND $where";
} }
if (static::$querySelect['where'] == '') { return new static();
static::$querySelect['where'] = $where_in; }
/**
* Defines WHERE using NOT IN in the SQL statement.
*
* @param string $column
* The column to compare.
*
* @param array $arr
* Array with all values to compare against the column.
*
* @return static
*/
public static function whereNotIn(
string $column,
array $arr,
): static {
$arrIn = [];
foreach ($arr as $value) {
$arrIn[] = static::bind($value);
}
$where = "$column NOT IN (" . join(', ', $arrIn) . ")";
if (static::$dbQuery['where'] == '') {
static::$dbQuery['where'] = $where;
} else { } else {
static::$querySelect['where'] .= " AND $where_in"; static::$dbQuery['where'] .= " AND $where";
} }
return new static(); return new static();
} }
/**
* Defines WHERE using IS NULL in the SQL statement.
*
* @param string $column
* The column to compare.
*
* @return static
*/
public static function whereNull(string $column): static
{
static::where($column, 'IS', 'NULL', true);
return new static();
}
/**
* Defines WHERE using IS NOT NULL in the SQL statement.
*
* @param string $column
* The column to compare.
*
* @return static
*/
public static function whereNotNull(string $column): static
{
static::where($column, 'IS NOT', 'NULL', true);
return new static();
}
/**
* Defines WHERE using EXISTS in the SQL statement.
*
* @param string $query
* SQL query.
*
* @return static
*/
public static function whereExists(string $query): static
{
static::where('', 'EXISTS', "($query)", true);
return new static();
}
/**
* Defines WHERE using NOT EXISTS in the SQL statement.
*
* @param string $query
* SQL query.
*
* @return static
*/
public static function whereNotExists(string $query): static
{
static::where('', 'NOT EXISTS', "($query)", true);
return new static();
}
/** /**
* Defines LEFT JOIN in the SQL statement. * Defines LEFT JOIN in the SQL statement.
* *
@@ -704,7 +856,7 @@ class Model
$operatorOrColumnB = '='; $operatorOrColumnB = '=';
} }
static::$querySelect['leftJoin'] .= ' LEFT JOIN ' . $table . ' ON ' . "$columnA$operatorOrColumnB$columnB"; static::$dbQuery['leftJoin'] .= ' LEFT JOIN ' . $table . ' ON ' . "$columnA$operatorOrColumnB$columnB";
return new static(); return new static();
} }
@@ -738,7 +890,14 @@ class Model
$operatorOrColumnB = '='; $operatorOrColumnB = '=';
} }
static::$querySelect['rightJoin'] .= ' RIGHT JOIN ' . $table . ' ON ' . "$columnA$operatorOrColumnB$columnB"; if (static::db()->getAttribute(PDO::ATTR_DRIVER_NAME) == 'sqlite') {
$currentTable = empty(static::$dbQuery['from']) ?
static::table() : static::$dbQuery['from'];
static::$dbQuery['from'] = $table;
return static::leftJoin($currentTable, $columnB, $operatorOrColumnB, $columnA);
}
static::$dbQuery['rightJoin'] .= ' RIGHT JOIN ' . $table . ' ON ' . "$columnA$operatorOrColumnB$columnB";
return new static(); return new static();
} }
@@ -772,22 +931,47 @@ class Model
$operatorOrColumnB = '='; $operatorOrColumnB = '=';
} }
static::$querySelect['innerJoin'] .= ' INNER JOIN ' . $table . ' ON ' . "$columnA$operatorOrColumnB$columnB"; static::$dbQuery['innerJoin'] .= ' INNER JOIN ' . $table . ' ON ' . "$columnA$operatorOrColumnB$columnB";
return new static(); return new static();
} }
/**
* Defines CROSS JOIN in the SQL statement.
*
* @param string $table
* Table to join with the current object's table.
*
* @param string $columnA
* Column to compare for the join.
*
* @param string $operatorOrColumnB
* Operator or column to compare as equal for the join
* if $columnB is not defined.
*
* @param string|null $columnB
* (Optional) Column to compare for the join.
*
* @return static
*/
public static function crossJoin(
string $table,
): static {
static::$dbQuery['crossJoin'] .= ' CROSS JOIN ' . $table;
return new static();
}
/** /**
* Defines GROUP BY in the SQL statement. * Defines GROUP BY in the SQL statement.
* *
* @param array $arr * @param array $columns
* Columns to group by. * Columns to group by.
* *
* @return static * @return static
*/ */
public static function groupBy(array $arr): static public static function groupBy(string ...$columns): static
{ {
static::$querySelect['groupBy'] = join(', ', $arr); static::$dbQuery['groupBy'] = join(', ', $columns);
return new static(); return new static();
} }
@@ -805,9 +989,9 @@ class Model
public static function limit(int $offsetOrQuantity, ?int $quantity = null): static public static function limit(int $offsetOrQuantity, ?int $quantity = null): static
{ {
if (is_null($quantity)) { if (is_null($quantity)) {
static::$querySelect['limit'] = $offsetOrQuantity; static::$dbQuery['limit'] = $offsetOrQuantity;
} else { } else {
static::$querySelect['limit'] = $offsetOrQuantity . ', ' . $quantity; static::$dbQuery['limit'] = $quantity . ' OFFSET ' . $offsetOrQuantity;
} }
return new static(); return new static();
@@ -828,7 +1012,9 @@ class Model
public static function orderBy(string $value, string $order = 'ASC'): static public static function orderBy(string $value, string $order = 'ASC'): static
{ {
if ($value == "RAND") { if ($value == "RAND") {
static::$querySelect['orderBy'] = 'RAND()'; $random = static::db()->getAttribute(PDO::ATTR_DRIVER_NAME) == 'mysql' ? 'RAND()' : 'RANDOM()';
static::$dbQuery['orderBy'] = $random;
return new static(); return new static();
} }
@@ -836,7 +1022,7 @@ class Model
$order = 'ASC'; $order = 'ASC';
} }
static::$querySelect['orderBy'] = $value . ' ' . $order; static::$dbQuery['orderBy'] = $value . ' ' . $order;
return new static(); return new static();
} }
@@ -857,25 +1043,25 @@ class Model
{ {
if (!$resetQuery) { if (!$resetQuery) {
$backup = [ $backup = [
'select' => static::$querySelect['select'], 'select' => static::$dbQuery['select'],
'limit' => static::$querySelect['limit'], 'limit' => static::$dbQuery['limit'],
'orderBy' => static::$querySelect['orderBy'], 'orderBy' => static::$dbQuery['orderBy'],
]; ];
} }
if ($useLimit && static::$querySelect['limit'] != '') { if ($useLimit && static::$dbQuery['limit'] != '') {
static::$querySelect['select'] = ['1']; static::$dbQuery['select'] = ['1'];
static::$querySelect['orderBy'] = ''; static::$dbQuery['orderBy'] = '';
$sql = 'SELECT COUNT(1) AS quantity FROM (' . static::buildQuery() . ') AS counted'; $sql = 'SELECT COUNT(1) AS quantity FROM (' . static::buildQuery() . ') AS counted';
$queryResult = static::query($sql, $resetQuery); $queryResult = static::query($sql, $resetQuery);
$result = $queryResult[0]['quantity']; $result = $queryResult[0]['quantity'];
} else { } else {
static::$querySelect['select'] = [ static::$dbQuery['select'] = [
"COUNT(" . static::table() . "." . static::$primaryKey . ") as quantity", "COUNT(" . static::table() . "." . static::$dbPrimaryKey . ") as quantity",
]; ];
static::$querySelect['limit'] = '1'; static::$dbQuery['limit'] = '1';
static::$querySelect['orderBy'] = ''; static::$dbQuery['orderBy'] = '';
$sql = static::buildQuery(); $sql = static::buildQuery();
$queryResult = static::query($sql, $resetQuery); $queryResult = static::query($sql, $resetQuery);
@@ -883,9 +1069,9 @@ class Model
} }
if (!$resetQuery) { if (!$resetQuery) {
static::$querySelect['select'] = $backup['select']; static::$dbQuery['select'] = $backup['select'];
static::$querySelect['limit'] = $backup['limit']; static::$dbQuery['limit'] = $backup['limit'];
static::$querySelect['orderBy'] = $backup['orderBy']; static::$dbQuery['orderBy'] = $backup['orderBy'];
} }
return (int)$result; return (int)$result;
@@ -901,7 +1087,7 @@ class Model
*/ */
public static function getById(mixed $id): ?static public static function getById(mixed $id): ?static
{ {
return static::where(static::$primaryKey, $id)->getFirst(); return static::where(static::$dbPrimaryKey, $id)->getFirst();
} }
/** /**
@@ -918,28 +1104,37 @@ class Model
public static function search(string $search, ?array $in = null): static public static function search(string $search, ?array $in = null): static
{ {
if ($in == null) { if ($in == null) {
$className = get_called_class(); $in = [];
$in = array_keys((new $className())->getVars()); $reflection = new \ReflectionClass(get_called_class());
foreach ($reflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) {
if (!in_array($property->getName(), static::$dbIgnoreSave)) {
$in[] = static::camelCaseToSnakeCase($property->getName());
}
}
} }
$search = static::bindValue($search); if (empty($in)) {
return new static();
}
$search = static::bind($search);
$where = []; $where = [];
if (DB_TYPE == 'sqlite') { if (static::db()->getAttribute(PDO::ATTR_DRIVER_NAME) == 'mysql') {
foreach ($in as $row) {
$where[] = "$row LIKE '%' || $search || '%'";
}
} else {
foreach ($in as $row) { foreach ($in as $row) {
$where[] = "$row LIKE CONCAT('%', $search, '%')"; $where[] = "$row LIKE CONCAT('%', $search, '%')";
} }
} else {
foreach ($in as $row) {
$where[] = "CAST($row AS TEXT) LIKE '%' || $search || '%'";
}
} }
if (static::$dbQuery['where'] == '') {
if (static::$querySelect['where'] == '') { static::$dbQuery['where'] = join(' OR ', $where);
static::$querySelect['where'] = join(' OR ', $where);
} else { } else {
static::$querySelect['where'] = static::$querySelect['where'] . ' AND (' . join(' OR ', $where) . ')'; static::$dbQuery['where'] = static::$dbQuery['where'] . ' AND (' . join(' OR ', $where) . ')';
} }
return new static(); return new static();
@@ -1003,22 +1198,4 @@ class Model
return $instances; return $instances;
} }
/**
* Allows defining an attribute's value as null.
* Only works for updating an element in the DB, not for inserting.
*
* @param array $attributes
* Attribute or array of attributes that will be set to null.
*
* @return void
*/
public function setNull(string ...$attributes): void
{
foreach ($attributes as $att) {
if (!in_array($att, $this->toNull)) {
$this->toNull[] = $att;
}
}
}
} }
+1 -1
View File
@@ -52,7 +52,7 @@ class Neuron
* @param string $index The name of the property being accessed. * @param string $index The name of the property being accessed.
* @return null Always returns null for undefined properties. * @return null Always returns null for undefined properties.
*/ */
public function __get(string $index): null public function __get(string $index)
{ {
return null; return null;
} }
+49 -20
View File
@@ -2,6 +2,8 @@
namespace Libs; namespace Libs;
use Exception;
/** /**
* Request - DuckBrain * Request - DuckBrain
* *
@@ -27,8 +29,6 @@ class Request extends Neuron
/** /**
* __construct * __construct
*
* @param string $path Current path, taking the DuckBrain installation as root.
*/ */
public function __construct() public function __construct()
{ {
@@ -38,7 +38,7 @@ class Request extends Neuron
$this->put = new Neuron(); $this->put = new Neuron();
$this->patch = new Neuron(); $this->patch = new Neuron();
$this->delete = new Neuron(); $this->delete = new Neuron();
$this->params = new Neuron(Router::$currentParams); $this->params = Router::$params ?? new Neuron();
$this->body = file_get_contents("php://input"); $this->body = file_get_contents("php://input");
$contentType = isset($_SERVER["CONTENT_TYPE"]) ? trim($_SERVER["CONTENT_TYPE"]) : ''; $contentType = isset($_SERVER["CONTENT_TYPE"]) ? trim($_SERVER["CONTENT_TYPE"]) : '';
@@ -61,39 +61,48 @@ class Request extends Neuron
} }
// Run configured validations // Run configured validations
if (!$this->validate()) { $this->validate();
exit();
}
} }
/** /**
* Starts the configured validation. * Starts the configured validation.
* *
* @return bool * On failure the single error message is built with Validator::message()
* and handed to onInvalid(), which throws by default: failures travel up
* as exceptions instead of answering HTTP here.
*
* @return void
* @throws Exception When a configured rule fails.
*/ */
public function validate(): bool public function validate(): void
{ {
$actual = match ($_SERVER['REQUEST_METHOD']) { $actual = match ($_SERVER['REQUEST_METHOD']) {
'POST', 'PUT', 'PATCH', 'DELETE' => $this->{strtolower($_SERVER['REQUEST_METHOD'])}, 'POST', 'PUT', 'PATCH', 'DELETE' => $this->{strtolower($_SERVER['REQUEST_METHOD'])},
default => $this->get default => $this->get
}; };
// Merge uploaded files ($_FILES) into the body data set so the file rules
// (file/image/mimes and the routed min/max/required...) can validate
// uploads through a Request. $this->post is left untouched.
$body = empty($_FILES)
? $actual
: new Neuron(array_merge(get_object_vars($actual), $_FILES));
if ( if (
Validator::validateList(static::paramRules(), $this->params) && Validator::validateList(static::paramRules(), $this->params) &&
Validator::validateList(static::getRules(), $this->get) && Validator::validateList(static::getRules(), $this->get) &&
Validator::validateList(static::rules(), $actual) Validator::validateList(static::rules(), $body)
) { ) {
return true; return;
} }
if (isset(static::messages()[Validator::$lastFailed])) { $error = Validator::message(
$error = static::messages()[Validator::$lastFailed]; Validator::$lastFailed,
} else { static::messages(),
$error = 'Error: validation failed of ' . preg_replace('/\./', ' as ', Validator::$lastFailed, 1); static::attributes()
} );
static::onInvalid($error); static::onInvalid($error);
return false;
} }
/** /**
@@ -136,16 +145,36 @@ class Request extends Neuron
return []; return [];
} }
/**
* Human-readable names for the fields.
*
* Forwarded to Validator::message() so the :attribute marker in the error
* text can be replaced with a friendlier name (e.g. "edad" => "la edad").
*
* @return array
*/
public function attributes(): array
{
return [];
}
/** /**
* Function to execute when an invalid value has been detected. * Function to execute when an invalid value has been detected.
* *
* The default implementation always throws a generic \Exception carrying
* the single error message and HTTP 422 as its code; the framework's
* exception boundary (Router::apply) renders the response. Override it to
* throw a more specific exception type instead. The never return type is
* the contract: an override that returned would let the request continue
* with invalid data, so PHP rejects such an override at compile time.
*
* @param string $error * @param string $error
* *
* @return void * @return never
* @throws Exception
*/ */
public function onInvalid(string $error): void public function onInvalid(string $error): never
{ {
http_response_code(422); throw new Exception($error, 422);
print($error);
} }
} }
+110 -13
View File
@@ -15,28 +15,40 @@ namespace Libs;
*/ */
class Router class Router
{ {
private static $get = []; private static array $get = [];
private static $post = []; private static array $post = [];
private static $put = []; private static array $put = [];
private static $patch = []; private static array $patch = [];
private static $delete = []; private static array $delete = [];
/**
* Stores a temporary stack of middlewares to be applied to routes defined
* within a specific scope, typically configured by `withMiddleware()`.
* These middlewares are active only during the execution of the scope's callback.
*
* @var array $withMiddlewares<callable>
*/
private static array $withMiddlewares = [];
/** /**
* Stores the method and index of the last configured route, e.g., ['get', 0]. * Stores the method and index of the last configured route, e.g., ['get', 0].
* Used for chaining methods like middleware() or reconfigure(). * Used for chaining methods like middleware() or reconfigure().
* *
* @var array|null * @var array $last
*/ */
private static $last; private static array $last;
/** /**
* Stores the parameters extracted from the current matching route. * Stores the parameters extracted from the current matching route.
* *
* @var array * @var Neuron $params
*/ */
public static $currentParams = []; public static Neuron $params;
/** /**
* The callback function to be executed when no route matches. * The callback function to be executed when no route matches.
* *
* @var callable|string * @var callable $notFoundCallback
*/ */
public static $notFoundCallback = 'Libs\Router::defaultNotFound'; public static $notFoundCallback = 'Libs\Router::defaultNotFound';
@@ -52,6 +64,54 @@ class Router
echo '<h2 style="text-align: center;margin: 25px 0px;">Error 404 - Page Not Found</h2>'; echo '<h2 style="text-align: center;margin: 25px 0px;">Error 404 - Page Not Found</h2>';
} }
/**
* The callback function to be executed when the router's boundary
* catches an exception thrown anywhere in the matched route chain.
* It receives the exception as a named argument: the handler must
* declare its parameter as $exception (typeable as \Throwable).
*
* @var callable $exceptionCallback
*/
public static $exceptionCallback = 'Libs\Router::defaultException';
/**
* Default callback function for exception responses.
*
* The HTTP status comes from the exception's code only when it is an
* integer in the 400-599 range; anything else (0, arbitrary codes, the
* SQLSTATE string carried by PDOException) answers 500. The body is
* negotiated from the request's Accept header: JSON when the client asks
* for application/json, plain text otherwise. The trace is serialized
* from getTraceAsString() because json_encode() of a Throwable yields an
* empty object: its properties are protected.
*
* @param \Throwable $exception
*
* @return void
*/
public static function defaultException(\Throwable $exception): void
{
$code = $exception->getCode();
http_response_code(is_int($code) && $code >= 400 && $code <= 599 ? $code : 500);
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
if (str_contains($accept, 'application/json')) {
header('Content-Type: application/json');
print(json_encode([
'error' => $exception->getMessage(),
'exception' => get_class($exception),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => explode(PHP_EOL, $exception->getTraceAsString()),
]));
return;
}
print($exception->getMessage() . PHP_EOL . $exception->getTraceAsString());
}
/** /**
* __construct * __construct
*/ */
@@ -124,7 +184,6 @@ class Router
public static function redirect(string $path): void public static function redirect(string $path): void
{ {
header('Location: ' . static::basePath() . ltrim($path, '/')); header('Location: ' . static::basePath() . ltrim($path, '/'));
exit;
} }
/** /**
@@ -132,7 +191,7 @@ class Router
* Only one middleware can be added at a time. * Only one middleware can be added at a time.
* *
* @param callable $callback * @param callable $callback
* @param int $priority Optional priority for the middleware execution order. * @param int|null $priority Optional priority for the middleware execution order.
* *
* @return static * @return static
* Returns the current instance. * Returns the current instance.
@@ -163,6 +222,28 @@ class Router
return new static(); return new static();
} }
/**
* Temporarily applies a set of middlewares to routes defined within a given callback.
* The middlewares are only active for the duration of the callback execution
* and are reset afterwards.
*
* @param callable $middleware
* @param callable $callback
*
* @return static
*/
public static function withMiddleware(callable $middleware, callable $callback): static
{
$currentMiddlewares = static::$withMiddlewares;
array_unshift(static::$withMiddlewares, $middleware);
$callback();
static::$withMiddlewares = $currentMiddlewares; // Restore withMiddleware
return new static();
}
/** /**
* Reconfigures the final callback of the last route. * Reconfigures the final callback of the last route.
* *
@@ -222,6 +303,11 @@ class Router
static::$$method[] = static::parse($path, $callback); static::$$method[] = static::parse($path, $callback);
static::$last = [$method, count(static::$$method) - 1]; static::$last = [$method, count(static::$$method) - 1];
foreach (static::$withMiddlewares as $middleware) {
static::middleware($middleware);
}
return new static(); return new static();
} }
@@ -324,12 +410,19 @@ class Router
/** /**
* Applies the route configuration. * Applies the route configuration.
* *
* This method is the framework's error boundary: any \Throwable thrown
* while running the matched route's callback chain, while printing the
* returned data, or while resolving the not-found callback is caught
* here and rendered through $exceptionCallback. A failing handler is
* deliberately left uncaught (double failure falls back to PHP itself).
*
* @param string|null $path (optional) Path to use. If not defined, it detects the current path. * @param string|null $path (optional) Path to use. If not defined, it detects the current path.
* *
* @return void * @return void
*/ */
public static function apply(?string $path = null): void public static function apply(?string $path = null): void
{ {
try {
$path = $path ?? static::currentPath(); $path = $path ?? static::currentPath();
$routers = match ($_SERVER['REQUEST_METHOD']) { // Selects an array of routers based on the method $routers = match ($_SERVER['REQUEST_METHOD']) { // Selects an array of routers based on the method
'POST' => static::$post, 'POST' => static::$post,
@@ -345,9 +438,10 @@ class Router
// Checking and storing the variable parameters of the route // Checking and storing the variable parameters of the route
if (isset($matches[1])) { if (isset($matches[1])) {
static::$params = new Neuron();
foreach ($matches as $index => $match) { foreach ($matches as $index => $match) {
$paramName = $router['paramNames'][$index - 1]; $paramName = $router['paramNames'][$index - 1];
static::$currentParams[$paramName] = urldecode($match[0]); static::$params->{$paramName} = urldecode($match[0]);
} }
} }
@@ -368,5 +462,8 @@ class Router
// If no router matches, call $notFoundCallBack // If no router matches, call $notFoundCallBack
Synapsis::resolve(static::$notFoundCallback); Synapsis::resolve(static::$notFoundCallback);
} catch (\Throwable $exception) {
Synapsis::resolve(static::$exceptionCallback, ['exception' => $exception]);
}
} }
} }
+21 -4
View File
@@ -66,11 +66,13 @@ class Synapsis
* Resolves and injects dependencies for a callable and returns its result. * Resolves and injects dependencies for a callable and returns its result.
* *
* @param callable $action * @param callable $action
* @param array<string, mixed> $named Associative array of values injected into the callable's
* parameters by exact name match. Consumed in resolveParameterValues().
* *
* @return mixed * @return mixed
* @throws Exception If an unhandled callable type is provided. * @throws Exception If an unhandled callable type is provided.
*/ */
public static function resolve(callable $action): mixed public static function resolve(callable $action, array $named = []): mixed
{ {
if ($action instanceof Closure) { // If it's an anonymous function if ($action instanceof Closure) { // If it's an anonymous function
$reflectionCallback = new ReflectionFunction($action); $reflectionCallback = new ReflectionFunction($action);
@@ -90,7 +92,7 @@ class Synapsis
// Get the parameters // Get the parameters
return call_user_func_array( return call_user_func_array(
$action, $action,
static::resolveParameterValues($reflectionCallback->getParameters()) static::resolveParameterValues($reflectionCallback->getParameters(), $named)
); );
} }
@@ -132,14 +134,27 @@ class Synapsis
* Resolves parameter values by injecting dependencies. * Resolves parameter values by injecting dependencies.
* *
* @param array<ReflectionParameter> $parameters * @param array<ReflectionParameter> $parameters
* @param array<string, mixed> $named
* Values injected into parameters whose name matches the key,
* taking precedence over optional defaults and DI resolution.
* Keys matching no parameter are ignored.
* *
* @return array<mixed> * @return array<mixed>
* @throws Exception If a primitive parameter does not have a default value. * @throws Exception If a primitive parameter does not have a default value.
*/ */
public static function resolveParameterValues(array $parameters): array public static function resolveParameterValues(array $parameters, array $named = []): array
{ {
$values = []; $values = [];
foreach ($parameters as $parameter) { foreach ($parameters as $parameter) {
if ($parameter->isVariadic()) { // Skip variatic parameters
continue;
}
if (array_key_exists($parameter->getName(), $named)) { // Named values win over defaults and DI
$values[] = $named[$parameter->getName()];
continue;
}
if ($parameter->isOptional()) { // Always use the default value first if ($parameter->isOptional()) { // Always use the default value first
$values[] = $parameter->getDefaultValue(); $values[] = $parameter->getDefaultValue();
continue; continue;
@@ -148,7 +163,9 @@ class Synapsis
$paramType = $parameter->getType(); $paramType = $parameter->getType();
if ($paramType === null) { if ($paramType === null) {
// If no type is declared, and it's not optional, we cannot resolve it. // If no type is declared, and it's not optional, we cannot resolve it.
throw new Exception('Untyped parameters expect at least a default value or must be optional.'); throw new Exception(
"Untyped parameter '{$parameter}' expect at least a default value or must be optional."
);
} }
if ($paramType->isBuiltin()) { if ($paramType->isBuiltin()) {
+1072 -29
View File
File diff suppressed because it is too large Load Diff