Compare commits

..

163 Commits

Author SHA1 Message Date
kj
7b1d5e42bd test(Router): cover default callback resolution through Synapsis 2026-09-08 12:05:55 -03:00
kj
26589062f5 refactor(Router): use string callable syntax for default callbacks 2026-09-08 12:05:35 -03:00
kj
5022fdac75 refactor(router): remove exit statement from redirect method 2026-09-07 19:59:36 -03:00
kj
70b1016194 refactor(autoload): remove global exception handler 2026-09-07 17:34:58 -03:00
kj
86ab8b979a test(router): verify custom exceptions reach exception callback 2026-09-07 17:34:14 -03:00
kj
9ddb00e719 test(request): cover exception-based validation failures 2026-09-07 17:21:47 -03:00
kj
a534613d26 refactor(request): propagate validation failures as exceptions 2026-09-07 17:20:19 -03:00
kj
65380f37c6 test(Router): Add unit tests for defaultException and apply flows 2026-09-07 17:04:52 -03:00
kj
5f3fc16735 feat(router): add error boundary with exception callback 2026-09-07 17:04:20 -03:00
kj
146aed0db8 test(synapsis): cover named argument injection in resolve 2026-09-07 16:18:09 -03:00
kj
9dbfd7da8e feat: add named parameter injection to resolve() 2026-09-07 16:17:34 -03:00
kj
3e7c367182 test(integration): cover query failure state cleanup 2026-09-05 16:47:39 -03:00
kj
25db49f4bd fix(model): reset query state when database query fails 2026-09-05 16:45:02 -03:00
kj
9cb41d51d0 fix(model): use RANDOM() for non-MySQL databases in orderBy 2026-09-05 16:37:37 -03:00
kj
d0c4b3c503 feat(test): add multi-engine integration matrix 2026-09-05 16:15:28 -03:00
kj
48d3ed6b3f docs(makefile): document develop/master publish workflow 2026-09-05 14:17:14 -03:00
kj
d9ac4c3f14 build: add Makefile with test and publish targets 2026-09-05 14:09:40 -03:00
kj
d594aa3ec1 test(unit): Add Neuron and Validator regression tests 2026-09-05 13:43:35 -03:00
kj
bf03e10b54 test(bootstrap): add smoke test for autoload and test DB 2026-09-05 13:25:47 -03:00
kj
f1bda46723 test(factories): add abstract Factory base class 2026-09-05 12:37:41 -03:00
kj
dbbde42fe7 test: add base TestCase and bootstrap for in-memory SQLite 2026-09-05 12:33:20 -03:00
kj
7494ec5fda chore: add PHPUnit configuration and unit tests directory 2026-09-05 12:11:08 -03:00
kj
c0e5cf79d0 build: Add composer setup with phpunit and faker 2026-09-05 12:02:34 -03:00
kj
a4fcd21492 chore: add .gitignore for PHP project 2026-09-05 11:50:27 -03:00
kj
a4611e8718 chore(src): rename .keep files to .gitkeep
Use the conventional `.gitkeep` filename so Git tooling recognizes
these placeholder files by default.
2026-09-05 11:35:04 -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
2b5d19ecd5 fix(synapsis): Skip variadic parameters in argument resolution 2026-04-15 16:35:32 -03:00
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
b6baeef32f style(Synapsis): Wrap long exception message for readability 2026-03-16 06:25:09 -03:00
159da01d5c fix(synapsis): Include parameter name in untyped parameter error 2026-03-16 06:24:16 -03:00
3770dd6d9b refactor(model): Track model persistence state 2026-03-11 18:48:32 -03:00
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
kj
7f62e06ff9 docs: Improve documentation and translate comments to English 2025-10-10 21:18:22 -03:00
kj
674c9d5ff4 fix(validator): Improve filter_var robustness in validation methods 2025-10-10 21:17:41 -03:00
kj
2f50532e13 refactor(Model): Type hint setNull variadic arguments 2025-10-10 21:06:11 -03:00
kj
b41514a491 fix(di): Improve parameter resolution and validation 2025-10-10 19:15:07 -03:00
kj
a1a15f492c refactor!: add Synapsis for dependency resolution and injection
- Remove Middleware class and custom callback handling logic.
- Implement Synapsis as a dependency injection container for automatic
resolution.
- Refactor Router to use Synapsis for process route callbacks and not found
handler.
- Update Request to remove middleware-specific properties and use
Router::$currentParams for path parameters.
2025-10-10 17:44:49 -03:00
kj
b19e7d8789 Remove a forgotten debug line. 2025-09-07 18:52:33 -03:00
kj
4dfdb52519 Remove unnecessary and really never-used tableSufix property. 2025-09-07 15:25:48 -03:00
kj
b0891885f9 BREAKING CHANGE: Make select, from, and setNull methods variadic. 2025-09-07 15:20:06 -03:00
kj
b282d5479f Convert object/database naming conventions.
Adhere to PSR-12 by converting object properties to "lowerCamelCase" for objects
and "snake_case" for database interactions.

Backward compatibility is maintained: object properties already using
"snake_case" will continue to work without issue.
2025-09-07 15:01:45 -03:00
kj
c9f467345b BREAKING CHANGE: Adhere to PSR-12 coding standards.
- Model: where_in method was renamed as whereIn.
2025-09-07 11:07:07 -03:00
kj
0f46848d15 Remove the predefined 'id' property.
This property is not necessary and gives less flexibility.
2025-08-15 14:51:16 -03:00
kj
b2cb8d6883 Allow statically call beginTransaction, rollback and commit methods. 2025-07-15 18:12:41 -03:00
kj
e9126e7cde Fix: Implicitly marking parameter as nullable is deprecated.
PHP 8.4 deprecation.
2025-06-07 14:27:47 -03:00
kj
7169d2cae3 Fix: render is not using the defined extension. 2025-06-07 14:17:11 -03:00
kj
66b2bc0d91 Remove unnecesary php close. 2025-06-07 14:14:16 -03:00
kj
c8ab2aa2cc Remove unnecesary echo. 2025-05-20 12:49:20 -03:00
kj
1e302a9ea7 BREAKING CHANGE: Change unnecesary false return type. 2025-04-19 15:44:16 -03:00
kj
d0d0d4dc76 Verify if a valid http query string after run parse_str. 2025-02-20 08:22:47 -03:00
kj
595e9c1316 Save body request as a property. 2025-02-20 08:22:37 -03:00
kj
45abea5301 Add delete request params. 2025-02-20 06:28:03 -03:00
kj
d441f001ec Add type of items on array on dockblock for "all" method. 2025-02-03 16:04:39 -03:00
kj
19da122e05 Add type of items on array on dockblock for get method. 2025-02-03 16:02:27 -03:00
KJ
1a0164c8ed Change static methods to non-static and made onInvalid public. 2024-10-30 11:53:44 -04:00
KJ
ad9f8ec67d Remove unnecesary brackets. 2024-10-29 19:12:25 -04:00
KJ
31c5c63952 Remove innecesary return. 2024-10-29 19:10:47 -04:00
KJ
6aef212350 Fix className not returning the classname in the right format. 2024-10-25 10:40:57 -04:00
KJ
c600688725 Improve return array dockblocks. 2024-09-23 18:09:38 -04:00
KJ
3e27b1b7af Allow null on enum properties. 2024-09-23 15:06:44 -04:00
KJ
73b7b8f72a Change required valitator to not allow empty values and add exists.
The exists validator do the same as the old required.
2024-09-18 14:33:33 -04:00
KJ
7baad428ec Refactor request library. 2024-09-08 14:43:56 -04:00
KJ
3d2a607768 Fix where_in is wiping previous where/and/or.
For now, works as an AND, but maybe later, same as where will exists
new methods: AndIn and OrIN.
2024-08-30 16:26:03 -04:00
KJ
df424ffab5 Model properties now can be typed as enums.
With this PHP 8.0 support is dropped.
2024-08-27 19:01:02 -04:00
KJ
daf7250882 Catch and verify put and patch input values. 2024-08-13 10:22:44 -04:00
KJ
05cd83fd10 Remove unused variable. 2024-07-31 03:29:49 -04:00
KJ
6b470a181d Fix: Remove unnecesary parameter. 2024-07-10 09:06:51 -04:00
KJ
7beb161d2b Ensure db is in transaction to commit or rollback. 2024-06-04 07:18:30 -04:00
KJ
701caae7eb Change route method to static. 2024-05-29 13:24:20 -04:00
KJ
100bdfe006 Change private method to protected instead. 2024-05-28 22:27:20 -04:00
KJ
f1b79fdbc0 Add http code 422 on verification failed. 2024-05-25 17:41:33 -04:00
KJ
406f9a10a1 Add head comment. 2024-05-25 17:19:08 -04:00
KJ
cc3cb6be41 Fix: a return was forgot. 2024-05-25 17:11:07 -04:00
KJ
59fff2a586 Add validation on Request. 2024-05-25 16:59:59 -04:00
KJ
cd1685d2e7 fix on a docblock. 2024-05-21 15:06:02 -04:00
KJ
b85fb7e034 Allow configure SITE_URL with or without slash at end. 2024-05-16 13:48:49 -04:00
KJ
a10308a8f6 Fix route and redirect methods error when path not start with slash. 2024-05-16 13:20:35 -04:00
KJ
9a1e5a2379 Add some explanatory comments to config. 2024-05-14 02:54:23 -04:00
KJ
fa60ec5bb4 Move constant definition to config.php 2024-05-14 02:53:39 -04:00
KJ
b294b1d627 Update readme.
Fix description and add links to starting manual.
2024-05-13 01:08:44 -04:00
KJ
27b5ed6015 Convert model name from PascalCase to snake_case (table name). 2024-05-12 04:38:45 -04:00
KJ
f9b2c678f7 Support multiple databases at same time. 2024-05-12 03:47:09 -04:00
KJ
e294eaa7af Add route function for View objects. 2024-05-09 23:12:59 -04:00
KJ
186d640c8f fix: an empty SITE_URL return a incorrect value of basePath. 2024-05-09 23:10:58 -04:00
KJ
c65fcd27c8 Create new ROOT_CORE constant. 2024-05-09 18:36:48 -04:00
KJ
f5f803dde2 Reorganize code for PSR and others code prettify. 2024-05-09 15:13:52 -04:00
KJ
6a1085b224 Allow Neuron as $params on view render. 2024-05-09 14:56:07 -04:00
KJ
64bcfa0b00 Add Lib\Request. 2024-05-05 09:38:10 -04:00
KJ
cd01ab9e72 Change switch to match. 2023-10-20 16:26:17 -04:00
KJ
e2094ccb4a Add option to use another extensions, js and css methods. 2023-09-15 21:26:53 -04:00
KJ
341d7837a1 Add Router::reconfigure method. 2023-09-08 22:42:50 -04:00
KJ
030e1079dc add mising types, patch method and a new way to set middleware
- Removed resumed way to set controllers and middlewares:
  Now, the method of routers and middlewares need to be
  callables.
- Add patch method in order to support PATCH http method.
- Add a way to get add more middlewares after. For Example:

  <?php
    use Libs\Router;

    Router::get('/some-route', 'somefunction')
      ->middleware('firstmiddleware');
    Router::get('/other-route', 'otherfunction');

    // Recover first router and add a middleware
    Router::get('/some-route')->middleware('secondmiddleware');
  ?>
2023-08-22 01:48:04 -04:00
KJ
af673a68b8 Add exit after redirect in order to avoid other erros. 2023-08-09 08:54:17 -04:00
KJ
eff0b86762 Allow construct Neuron with named arguments.
Example:

<?php
$instance = new Neuron(stringValue: 'Hello world', integerValue: 50,
boolValue: false);

echo $instance->stringValue; // "Hello world" will be printed.
?>

Also, is possible to send infinite params without names and his names
will be numeric similar as a non-asociatie array, but as Neuron object.

Example:

<?php
$str = 'Hello world';
$int = 50;
$con = false;

$instance = new Neuron($str, $int);

echo $instance->{0}; // "Hello world" will be printed.
?>
2023-06-04 14:47:58 -04:00
KJ
39a1f9d85a Improve magic function __get.
Is not necessary another conditionals. When __get is called
is only when the the property is not defined, so
only need return null in order to avoid the
PHP notice of undefined property.
2023-06-04 14:34:24 -04:00
kj
d48f24ed98 Improve return types. 2023-04-22 05:32:37 -04:00
kj
b326c8e1d0 Fix mising use. 2023-04-16 23:47:49 -04:00
kj
1267d6b2f6 Fix deprecation warnings on PHP8.2 2023-04-15 13:17:00 -04:00
kj
a8d95cb55b Fix empty application/json. 2023-04-14 16:43:50 -04:00
kj
06b7907f7c Define mixed type to param $id on getById method. 2023-03-25 21:28:25 -04:00
kj
4d052efba6 Change return type from mixed to ?Model on getFirts and getById methods. 2023-03-25 18:28:21 -04:00
kj
11141a0eee Allow send string to setNull method. 2023-03-25 12:27:33 -04:00
kj
3c8a21161f Add missing docblocks and return types. 2023-03-25 12:26:42 -04:00
kj
1bde430251 Improve Neuron and allow use to clone another object. 2023-03-15 22:16:05 -04:00
kj
7d3d1615d5 Fix View:txt and add charset. 2022-11-22 00:59:20 -04:00
kj
404bd59569 Improve where, and, or and search method.
- static::$querySelect['AndOr'] is REMOVED.
- where, and, or and search now use only the
  static::$querySelect['where'] index.
- removed unnecesary brackets on buildQuery.
2022-08-22 15:26:32 -04:00
kj
53bdc92344 Add bindValue method to Libs\Model. 2022-08-18 16:38:40 -04:00
kj
ad7b78f427 Fix prepare variables when special where/and/or columns. 2022-08-12 06:31:54 -04:00
kj
38d2a90318 Fix count when using joins. 2022-08-12 05:46:24 -04:00
kj
2411704662 Add sqlite support. 2022-08-04 05:30:22 -04:00
kj
08d92a2b81 Allow usage of bool types on the ORM. 2022-08-01 08:27:49 -04:00
kj
8d47e10d7a Fix resetQuery. 2022-07-29 22:23:07 -04:00
kj
2c7938a0c3 Set default fetch mode to fetch assoc. 2022-07-24 00:29:34 -04:00
kj
c57ca191b1 Rename limit() variables with better names and add description. 2022-07-19 12:08:22 -04:00
kj
f4eabe6d54 Minor fixes.
- Add some uses.
- Fix typo and order error con docl block.
2022-07-14 22:39:38 -04:00
kj
83d9ba55aa Update migration notes. 2022-07-14 22:08:43 -04:00
kj
bad7bf0e65 Update config template. 2022-07-14 22:03:07 -04:00
kj
5284be2b1b Update org files. 2022-07-14 21:59:27 -04:00
kj
0db6e4a021 rework to change from MySQLi to PDO. 2022-07-14 21:41:48 -04:00
kj
afb4c914a1 Fix error on where_in with string values. 2022-07-10 12:10:01 -04:00
kj
4c4fe6f1f7 Change View to work as instance and render text and json. 2022-06-25 21:11:11 -04:00
46 changed files with 8028 additions and 1199 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/vendor/
/.phpunit.cache/
*.sqlite
/.publish/

View File

@@ -1,6 +1,9 @@
<IfModule mod_rewrite.c>
RewriteEngine On
# Bloquear acceso a las carpetas vendor/ y src/
RewriteRule ^(vendor|src)/.*$ - [F,L]
# Handle Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f

197
Makefile Normal file
View File

@@ -0,0 +1,197 @@
# DuckBrain development harness — Makefile
#
# This file (along with tests/, phpunit.xml, composer.*, .gitignore) lives
# ONLY on the develop branch. master is a publish-only vitrine that contains
# nothing but the readable artifact: nobody commits to master directly, and
# no file outside the WHITELIST below ever reaches it.
#
# Daily loop:
# make test run the unit suite (sqlite :memory:, no containers, no
# network; installs dev dependencies first if missing)
# ...then develop on develop and commit there as usual.
#
# Multi-engine regression net (run it whenever Model/Database change):
# make integration full matrix: brings the test services up,
# runs the Integration suite on sqlite, mysql
# and pgsql, then takes the services down even
# if a leg fails or you interrupt it
# make integration-engine ENGINE=mysql
# single leg (assumes services are already up);
# the fastest inner loop while iterating
# make db-up / make db-down manage the rootless podman test containers
# (mariadb:11, postgres:16-alpine); idempotent
#
# Hard port constraint: the services must bind default ports 3306/5432 on
# localhost because Database.php's DSN has no port field; stop any local
# server on those ports first (db-up aborts naming the port).
#
# Publishing ritual (human only — AI agents must never run it; it commits):
# 1. make publish MSG="feat: ..." (MSG optional, defaults to "sync: <date>")
# 2. review the new master commit (git log -1 refs/heads/master); if the
# generated message needs polish, rewrite it BEFORE pushing, e.g.
# from Emacs
# 3. git push origin master (manual on purpose: pushing is a
# decision, never a side effect)
#
# How publish builds master — wipe-and-rebuild mirror, no merges, no
# cherry-picks (see design decisions behind establish-dev-publish-workflow):
# - refuses to run off develop or with a dirty working tree
# - checks master out into a disposable worktree under .publish/
# (your current working tree is never touched)
# - for each WHITELIST path: removes master's copy and checks out develop's,
# so deleted/renamed artifact files never survive as ghosts in master
# - BLACKLIST then subtracts noise found INSIDE whitelisted paths
# - creates the commit only when the artifact actually changed; its body
# lists the develop commits (after the last-sync tag) that touched
# WHITELIST paths — pure-noise commits are filtered out automatically
# - moves the last-sync tag to develop's tip, removes the worktree, and
# prints any root entry left out of WHITELIST (add it if it belongs to
# the artifact)
SHELL := /bin/bash
.PHONY: test publish db-up db-up-mariadb db-up-postgres db-down integration integration-engine
PHPUNIT := vendor/bin/phpunit
# Engine for single-leg integration runs (services must already be up).
ENGINE ?= sqlite
# --- publish configuration -------------------------------------------------
WHITELIST := src config.php index.php autoload.php .htaccess readme.org
BLACKLIST :=
DEVELOP_BRANCH := develop
DEVELOP_REF := refs/heads/$(DEVELOP_BRANCH)
MASTER_BRANCH := master
MASTER_REF := refs/heads/$(MASTER_BRANCH)
LAST_SYNC_TAG := last-sync
MSG ?= sync: $(shell date +%Y-%m-%d)
# --- integration database services (podman rootless) -----------------------
# Default ports are non-negotiable: Database.php's DSN carries no port field,
# so the engines must answer on 3306/5432. If a local server owns those
# ports, stop it before `make db-up`.
MARIADB_NAME := duckbrain-test-mariadb
POSTGRES_NAME := duckbrain-test-postgres
MARIADB_IMAGE := docker.io/library/mariadb:11
POSTGRES_IMAGE := docker.io/library/postgres:16-alpine
TEST_DB_NAME := duckbrain_test
TEST_DB_USER := duckbrain
TEST_DB_PASS := duckbrain
DB_READY_SECS := 240
test:
@test -x $(PHPUNIT) || composer install --no-interaction
./$(PHPUNIT) --testsuite Unit
publish:
@if [ "$$(git branch --show-current)" != "$(DEVELOP_BRANCH)" ]; then \
echo "ABORT: publish must be invoked from $(DEVELOP_BRANCH) (current: '$$(git branch --show-current)')"; \
exit 1; \
fi
@if [ -n "$$(git status --porcelain)" ]; then \
echo "ABORT: working tree is dirty; commit your changes on $(DEVELOP_BRANCH) before publishing:"; \
git status --short; \
exit 1; \
fi
git worktree remove --force .publish/master 2>/dev/null || true
git worktree add --quiet .publish/master $(MASTER_BRANCH)
@test "$$(git -C .publish/master symbolic-ref HEAD)" = "$(MASTER_REF)" || { \
echo "ABORT: publish worktree HEAD is not attached to $(MASTER_REF)"; \
git worktree remove --force .publish/master; exit 1; }
@for w in $(WHITELIST); do git -C .publish/master rm -rf --ignore-unmatch --quiet -- $$w; done
git -C .publish/master checkout $(DEVELOP_REF) -- $(WHITELIST)
@for b in $(BLACKLIST); do git -C .publish/master rm -rf --ignore-unmatch --quiet -- $$b; done
git -C .publish/master diff --quiet $(DEVELOP_REF) -- $(WHITELIST)
@if git -C .publish/master diff --cached --quiet; then \
echo "nothing to publish: master already mirrors $(DEVELOP_BRANCH)"; \
else \
set -e; \
if git rev-parse -q --verify tags/$(LAST_SYNC_TAG) >/dev/null; then \
RANGE=$(LAST_SYNC_TAG)..$(DEVELOP_REF); \
else \
echo "note: no $(LAST_SYNC_TAG) tag yet; summarizing full $(DEVELOP_BRANCH) history"; \
RANGE=$(DEVELOP_REF); \
fi; \
BODY=$$(git log $$RANGE --oneline -- $(WHITELIST)); \
{ printf '%s\n' "$(MSG)"; printf '\n'; printf '%s\n' "$$BODY"; } > .publish/msg.txt; \
git -C .publish/master commit --quiet -F "$$(pwd)/.publish/msg.txt"; \
echo "publish commit created; review/edit it (before pushing) with your git tool of choice"; \
fi
git tag -f $(LAST_SYNC_TAG) $(DEVELOP_REF) >/dev/null
git worktree remove .publish/master
@echo "root entries left behind in $(DEVELOP_BRANCH) (add to WHITELIST if they belong to the artifact):"; \
comm -23 <(git ls-tree --name-only $(DEVELOP_REF) | sort) \
<(for x in $(WHITELIST) $(BLACKLIST); do echo $$x; done | sort) | sed 's/^/ - /'
db-up: db-up-mariadb db-up-postgres
db-up-mariadb:
@if podman ps -a --format '{{.Names}}' | grep -qx $(MARIADB_NAME); then \
echo "reusing $(MARIADB_NAME)"; podman start $(MARIADB_NAME) >/dev/null; \
else \
(echo > /dev/tcp/127.0.0.1/3306) 2>/dev/null && { \
echo "ABORT: port 3306 already in use; stop the local MySQL/MariaDB server or free the port before db-up"; exit 1; } || true; \
podman run -d --name $(MARIADB_NAME) \
-e MARIADB_ROOT_PASSWORD=$(TEST_DB_PASS) \
-e MARIADB_DATABASE=$(TEST_DB_NAME) \
-e MARIADB_USER=$(TEST_DB_USER) \
-e MARIADB_PASSWORD=$(TEST_DB_PASS) \
-p 127.0.0.1:3306:3306 $(MARIADB_IMAGE) >/dev/null; \
fi
@echo "waiting for $(MARIADB_NAME) to accept connections..."; \
for i in $$(seq 1 $(DB_READY_SECS)); do \
podman exec $(MARIADB_NAME) mariadb-admin ping -h 127.0.0.1 -u $(TEST_DB_USER) -p$(TEST_DB_PASS) --silent >/dev/null 2>&1 \
&& echo "$(MARIADB_NAME) ready" && exit 0; \
sleep 2; \
done; \
echo "TIMEOUT: $(MARIADB_NAME) never became ready; inspect: podman logs $(MARIADB_NAME)"; exit 1
db-up-postgres:
@if podman ps -a --format '{{.Names}}' | grep -qx $(POSTGRES_NAME); then \
echo "reusing $(POSTGRES_NAME)"; podman start $(POSTGRES_NAME) >/dev/null; \
else \
(echo > /dev/tcp/127.0.0.1/5432) 2>/dev/null && { \
echo "ABORT: port 5432 already in use; stop the local PostgreSQL server or free the port before db-up"; exit 1; } || true; \
podman run -d --name $(POSTGRES_NAME) \
-e POSTGRES_DB=$(TEST_DB_NAME) \
-e POSTGRES_USER=$(TEST_DB_USER) \
-e POSTGRES_PASSWORD=$(TEST_DB_PASS) \
-p 127.0.0.1:5432:5432 $(POSTGRES_IMAGE) >/dev/null; \
fi
@echo "waiting for $(POSTGRES_NAME) to accept connections..."; \
for i in $$(seq 1 $(DB_READY_SECS)); do \
podman exec $(POSTGRES_NAME) pg_isready -U $(TEST_DB_USER) -d $(TEST_DB_NAME) >/dev/null 2>&1 \
&& echo "$(POSTGRES_NAME) ready" && exit 0; \
sleep 2; \
done; \
echo "TIMEOUT: $(POSTGRES_NAME) never became ready; inspect: podman logs $(POSTGRES_NAME)"; exit 1
db-down:
@for c in $(MARIADB_NAME) $(POSTGRES_NAME); do \
if podman ps -a --format '{{.Names}}' | grep -qx $$c; then \
podman stop $$c >/dev/null 2>&1; podman rm $$c >/dev/null 2>&1; echo "removed $$c"; \
else \
echo "$$c not present"; \
fi; \
done
integration:
@bash -c ' \
trap "$(MAKE) db-down" EXIT; \
trap "exit 130" INT; \
trap "exit 143" TERM; \
$(MAKE) --no-print-directory db-up || exit 1; \
rc=0; \
for e in sqlite mysql pgsql; do \
echo "============ leg: $$e ============"; \
$(MAKE) --no-print-directory integration-engine ENGINE=$$e || rc=1; \
done; \
echo "=================================="; \
if [ $$rc -eq 0 ]; then echo "integration: all legs green"; else echo "integration: at least one leg FAILED (rc=1)"; fi; \
exit $$rc \
'
integration-engine:
@test -x $(PHPUNIT) || composer install --no-interaction
DUCKBRAIN_TEST_DB=$(ENGINE) ./$(PHPUNIT) --testsuite Integration --cache-directory .phpunit.cache/$(ENGINE)

14
autoload.php Normal file
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;
}
});

15
composer.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "kj/duckbrain",
"description": "DuckBrain microframework - development harness (tests and tooling; never published to master).",
"type": "project",
"license": "MIT",
"require-dev": {
"fakerphp/faker": "^1.24",
"phpunit/phpunit": "^11.0"
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
}
}

1970
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,15 @@
<?php
define('dbhost', 'localhost');
define('dbname', '');
define('dbuser', '');
define('dbpass', '');
// Database configuration
define('DB_TYPE', 'mysql');
define('DB_HOST', 'localhost');
define('DB_NAME', '');
define('DB_USER', '');
define('DB_PASS', '');
// Site configuration
define('SITE_URL', '');
// Advanced configuration
define('ROOT_DIR', __DIR__);
?>
define('ROOT_CORE', ROOT_DIR . '/src');

View File

@@ -1,24 +1,15 @@
<?php
require_once('config.php');
// Incluir clases
spl_autoload_register(function ($className) {
$fp = str_replace('\\','/',$className);
$name = basename($fp);
$dir = dirname($fp);
$file = ROOT_DIR.'/src/'.$dir.'/'.$name.'.php';
if (file_exists($file)) {
require_once $file;
return;
}
});
use Libs\Loader;
use Libs\Router;
// Incluir routers
$routers = glob(ROOT_DIR.'/src/Routers/*.php');
require_once('autoload.php');
foreach($routers as $file){
require_once($file);
}
// Router autoload
Loader::load(ROOT_CORE . '/Routers/');
\Libs\Router::apply();
?>
// Other autoloads
Loader::load(ROOT_CORE . '/Autoloads/');
// Run the routers
Router::apply();

25
phpunit.xml Normal file
View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
requireCoverageMetadata="false"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Integration">
<directory>tests/Integration</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
</phpunit>

View File

@@ -1,21 +0,0 @@
# DuckBrain - PHP Microframework
Este microframework PHP tiene el objetivo de presentar un framework sencillo y potente que sea válido especialmente para proyectos pequeños o simples, sin limitar demasiado, ni depender de cosas que agranden innecesariamente proyectos.
Esto no es un intento de mi parte de reinventar la rueda, de hecho los primeros commits los hice con la intención de que solo fuera una prueba de concepto, pero me gustó como quedó y decidí darle continuidad hasta hacerlo usable para mi mismo.
Tuve la idea de hacer este conjunto de librerías/microframework luego de ver como proyectos relativamente pequeños eran hechos con frameworks demasiado pesados para lo que eran.
También revisé otros microframeworks y si bien me parecen una buena opción para hacer los trabajos, aún me quedaba la espinita de que seguían siendo muy grandes para que un programador novato pudiera leerlo, entenderlo y modificarlo, así que intenté hacer algo más simple aún y de ahí salió este proyecto.
Lo ideal sería mantener el código sencillo, lo suficiente como para que cualquier novato que sepa POO y PHP pueda leerlo rápido, entenderlo y modificarlo a gusto. Por este motivo también he decidido desligarlo en lo posible de composer o cualquier cosa similar, ya que no pocos programadores en etapa de aprendizaje al encontrarse con frameworks más complicados o con herramientas como composer, terminan trabajando con cosas que no comprenden ni pueden arreglar por si mismos en caso de fallo, ya que son completamente dependientes de dichas herramientas.
El código no es perfecto, pero lo iré perfeccionando, ya que es algo que estoy usando actualmente para mis trabajos, de modo que puedo ir viendo que cosas se pueden ir mejorando.
## Uso / Documentación
Queda pendiente, si quieres usarlo ya mismo, puedes leer los comeentarios que he colocado en el código.
## Contacto
Puedes encontrame en telegram como [@keyjay](https://telegram.me/keyjay) o contactarme mi correo: webmaster@outcontrol.net

119
readme.org Normal file
View File

@@ -0,0 +1,119 @@
#+TITLE: Duckbrain
#+AUTHOR: KJ
#+OPTIONS: toc:nil
Este conjunto de librerías (microframework, si así lo prefieres) tiene el objetivo de presentar una herramienta de trabajo sencilla y potente que sea válida especialmente para proyectos pequeños o simples, sin limitar, ni depender de cosas que agranden innecesariamente dichos proyectos.
Tuve la idea de hacer esta herramienta luego de ver como proyectos relativamente pequeños eran hechos con frameworks demasiado grandes para la envergadura de dichos proyectos. No es bueno matar moscas a cañonazos.
Del mismo modo revisé algunos microframeworks y si bien me parecen una buena opción, aún me quedaba la espinita de que seguían siendo muy complejos para que un programador novato pudiera leer su código, entenderlo y modificarlo, así que intenté hacer algo más simple
Lo que busco es mantener un código sencillo, lo suficiente como para que cualquier novato que sepa POO y PHP pueda leer su código rápidamente, entenderlo y modificarlo a gusto. Por este motivo también he decidido desligarlo en lo posible de composer o cualquier cosa similar, ya que no pocos programadores en etapa de aprendizaje al encontrarse con frameworks más complicados o con herramientas como composer, terminan trabajando con cosas que no comprenden ni pueden arreglar por si mismos en caso de fallo, llevándolos a la dependencia total de dichas herramientas.
* Uso / Documentación
Actualmente existe un manual de inicio que puedes leerlo desde [[https://tools.kj2.me/duckbrain/starting-manual.html][aquí]] o desde [[https://git.kj2.me/kj/duckbrain-docs/src/branch/master/starting-manual.org][aquí]].
Si quieres leer el manual de inicio en su formato original (necesitas un editor/lector que soporte org-mode) puedes descargarla desde [[https://tools.kj2.me/duckbrain/starting-manual.org][aquí]].
Para el resto de la documentación, el código usa [[https://www.phpdoc.org/][phpdoc]], por lo que puedes tener toda la documentación API desde tu IDE o Editor de texto preferido siempre que soporte dicha característica. O si lo prefieres, puedes generarla en unbonito htm local usando la herramienta [[https://docs.phpdoc.org/3.0/packages/phpDocumentor.html][phpDocumentor]].
* Soporte de gestores de bases de datos
En la siguiente tabla se encuentra la lista de estados de los gestores de bases de datos que he probado o que planeo probar para asegurarme de que sean realmente compatibles.
*Entiéndase*:
+ *ok* como que ha sido probado y funciona.
+ *En blanco* como que no ha sido probado aún.
+ *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.
+ *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 |
|------------------+---------------+---------+------------|
| db | ok | ok | ok |
|------------------+---------------+---------+------------|
| query | ok | ok | ok |
|------------------+---------------+---------+------------|
| resetQuery | ok | ok | ok |
|------------------+---------------+---------+------------|
| buildQuery | ok | ok | ok |
|------------------+---------------+---------+------------|
| getInstance | ok | ok | ok |
|------------------+---------------+---------+------------|
| getVars | ok | ok | ok |
|------------------+---------------+---------+------------|
| className | ok | ok | pk |
|------------------+---------------+---------+------------|
| table | ok | ok | ok |
|------------------+---------------+---------+------------|
| update | ok | ok | ok |
|------------------+---------------+---------+------------|
| beginTransaction | ok | ok | ok |
|------------------+---------------+---------+------------|
| rollBack | ok | ok | ok |
|------------------+---------------+---------+------------|
| commit | ok | ok | ok |
|------------------+---------------+---------+------------|
| add | ok | ok | ok |
|------------------+---------------+---------+------------|
| save | ok | ok | ok |
|------------------+---------------+---------+------------|
| delete | ok | ok | ok |
|------------------+---------------+---------+------------|
| select | ok | ok | ok |
|------------------+---------------+---------+------------|
| from | ok | ok | ok |
|------------------+---------------+---------+------------|
| where | ok | ok | ok |
|------------------+---------------+---------+------------|
| whereIn | ok | ok | ok |
|------------------+---------------+---------+------------|
| whereNotIn | ok | ok | ok |
|------------------+---------------+---------+------------|
| whereNull | ok | ok | ok |
|------------------+---------------+---------+------------|
| whereNotNull | ok | ok | ok |
|------------------+---------------+---------+------------|
| whereExists | ok | ok | ok |
|------------------+---------------+---------+------------|
| whereNotExists | ok | ok | ok |
|------------------+---------------+---------+------------|
| leftJoin | ok | ok | ok |
|------------------+---------------+---------+------------|
| rightJoin | ok | fixed | ok |
|------------------+---------------+---------+------------|
| innerJoin | ok | ok | ok |
|------------------+---------------+---------+------------|
| crossJoin | ok | ok | ok |
|------------------+---------------+---------+------------|
| and | ok | ok | ok |
|------------------+---------------+---------+------------|
| or | ok | ok | ok |
|------------------+---------------+---------+------------|
| groupBy | ok | ok | ok |
|------------------+---------------+---------+------------|
| limit | ok | ok | ok |
|------------------+---------------+---------+------------|
| orderBy | ok | 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
Puedes encontrame en telegram como [[https://telegram.me/keyjay][@keyjay]] o contactarme mi correo: webmaster@outcontrol.net

View File

@@ -1,38 +1,63 @@
<?php
namespace Libs;
use Exception;
use PDO;
use PDOException;
/**
* Database - DuckBrain
*
* Clase diseñada para crear y devolver una única instancia mysqli (database).
* Depende de manera forzada de que estén definidas las constantes:
* dbhost, dbname, dbpass y dbuser
* Class designed to create and return a single PDO instance (database).
*
* @author KJ
* @website https://kj2.me
* @licence MIT
* @license MIT
*/
namespace Libs;
use mysqli;
class Database extends \mysqli {
static private $db;
private function __construct() {}
class Database extends PDO
{
private static array $databases = [];
/**
* Devuelve una instancia homogénea (singlenton) a la base de datos.
*
* @return mysqli
* Private constructor to prevent direct instantiation.
*/
static public function getConnection() : mysqli {
if (!isset(self::$db)) {
self::$db = new mysqli(dbhost, dbuser, dbpass, dbname);
if (self::$db->connect_errno) {
echo '<style>body{white-space: pre-line;}</style>';
throw new \Exception('No se ha podido conectar a la base de datos.');
private function __construct()
{
}
/**
* Returns a homogeneous (singleton) instance of the database (PDO).
*
* @return PDO
* @throws Exception If there is an error connecting to the database.
*/
public static function getInstance(
string $type = 'mysql',
string $host = 'localhost',
string $name = '',
string $user = '',
string $pass = '',
): PDO {
$key = $type . '/' . $host . '/' . $name . '/' . $user;
if (empty(static::$databases[$key])) {
if ($type == 'sqlite') {
$dsn = $type . ':' . $name;
} else {
$dsn = $type . ':dbname=' . $name . ';host=' . $host;
}
try {
static::$databases[$key] = new PDO($dsn, $user, $pass);
} catch (PDOException $e) {
throw new Exception(
'Error at connect to database: ' . $e->getMessage()
);
}
static::$databases[$key]->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
static::$databases[$key]->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
}
return self::$db;
return static::$databases[$key];
}
}
?>

31
src/Libs/Loader.php Normal file
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);
}
}
}
}

View File

@@ -1,27 +0,0 @@
<?php
/**
* Middleware - DuckBrain
*
* Librería base para middlewares.
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
namespace Libs;
class Middleware {
/**
* Llama al siguiente callback.
*
* @param Neuron $req
*
* @return mixed
*/
public static function next(Neuron $req) {
$next = array_pop($req->next);
return call_user_func_array($next, [$req]);
}
}

1201
src/Libs/Model.php Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,828 +0,0 @@
<?php
/**
* ModelMysql - DuckBrain
*
* Modelo ORM para objetos que hagan uso de una base de datos MySQL.
* Depende de que exista Libs\Database para poder funcionar.
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
namespace Libs;
use Libs\Database;
use mysqli;
class ModelMySQL {
public ?int $id = null;
protected array $toNull = [];
static protected string $primaryKey = 'id';
static protected array $ignoreSave = ['id'];
static protected array $forceSave = [];
static protected string $table;
static protected string $tableSufix = 's';
static protected ?mysqli $db = null;
static protected array $querySelect = [
'select' => ['*'],
'where' => '',
'from' => '',
'leftJoin' => '',
'rightJoin' => '',
'innerJoin' => '',
'AndOr' => '',
'orderBy' => '',
'groupBy' => '',
'limit' => '',
'sql_calc_found_rows' => false
];
/**
* Sirve para obtener la instancia de la base de datos.
*
* @return mysqli
*/
protected static function db() : mysqli {
if (is_null(static::$db))
static::$db = Database::getConnection();
return static::$db;
}
/**
* Ejecuta una sentencia SQL en la base de datos.
*
* @param string $query
* Contiene la sentencia SQL que se desea ejecutar.
*
* @throws \Exception
* En caso de que la sentencia SQL falle, devolverá un error en pantalla.
*
* @return mixed
* Contiene el resultado de la llamada SQL (mysqli_result o bool).
*/
protected static function query($query) {
$db = static::db();
$result = $db->query($query);
if ($db->errno) {
echo '<style>body{white-space: pre-line;}</style>';
throw new \Exception(
"\nFallo al consultar la base de datos\n" .
"Errno: $db->errno\n" .
"Error: $db->error\n" .
"Query: $query\n"
);
}
return $result;
}
/**
* Reinicia la configuración de la sentencia SQL.
*/
protected static function resetQuery() {
static::$querySelect = [
'select' => ['*'],
'where' => '',
'from' => '',
'leftJoin' => '',
'rightJoin' => '',
'innerJoin' => '',
'AndOr' => '',
'orderBy' => '',
'groupBy' => '',
'limit' => '',
'sql_calc_found_rows' => false
];
}
/**
* Construye la sentencia SQL a partir static::$querySelect y una vez
* construída, llama a resetQuery.
*
* @param bool $resetQuery
* Indica si el query debe reiniciarse o no (por defecto es true).
*
* @return string
* Contiene la sentencia SQL.
*/
protected static function buildQuery(bool $resetQuery = true) : string {
if (static::$querySelect['sql_calc_found_rows'])
$sql = 'SELECT SQL_CALC_FOUND_ROWS '.join(', ', static::$querySelect['select']);
else
$sql = 'SELECT '.join(', ', static::$querySelect['select']);
if (static::$querySelect['from'] != '') {
$sql .= ' FROM '.static::$querySelect['from'];
} else {
$sql .= ' FROM '.static::table();
}
if(static::$querySelect['innerJoin'] != '') {
$sql .= static::$querySelect['innerJoin'];
}
if (static::$querySelect['leftJoin'] != '') {
$sql .= static::$querySelect['leftJoin'];
}
if(static::$querySelect['rightJoin'] != '') {
$sql .= static::$querySelect['rightJoin'];
}
if (static::$querySelect['where'] != '') {
$sql .= ' WHERE '.static::$querySelect['where'];
if (static::$querySelect['AndOr'] != '') {
$sql .= static::$querySelect['AndOr'];
}
}
if (static::$querySelect['groupBy'] != '') {
$sql .= ' GROUP BY '.static::$querySelect['groupBy'];
}
if (static::$querySelect['orderBy'] != '') {
$sql .= ' ORDER BY '.static::$querySelect['orderBy'];
}
if (static::$querySelect['limit'] != '') {
$sql .= ' LIMIT '.static::$querySelect['limit'];
}
if ($resetQuery)
static::resetQuery();
return $sql;
}
/**
* Crea una instancia del objeto actual a partir de un arreglo.
*
* @param mixed $elem
* Puede recibir un arreglo o un objeto que contiene los valores
* que tendrán sus atributos.
*
* @return ModelMySQL
* Retorna un objeto de la clase actual.
*/
protected static function getInstance(array $elem = []) : ModelMySQL {
$class = get_called_class();
$instance = new $class;
foreach ($elem as $key => $value) {
$instance->$key = $value;
}
return $instance;
}
/**
* Devuelve los atributos a guardar de la case actual.
* Los atributos serán aquellos que seran public y
* no esten excluidos en static::$ignoresave y aquellos
* que sean private o protected pero estén en static::$forceSave.
*
* @return array
* Contiene los atributos indexados del objeto actual.
*/
protected function getVars() : array {
$reflection = new \ReflectionClass($this);
$properties = $reflection->getProperties(\ReflectionProperty::IS_PUBLIC);
$result = [];
foreach($properties as $property) {
$att = $property->name;
$result[$att] = isset($this->$att)
? $this->$att : null;
}
foreach (static::$ignoreSave as $del) {
unset($result[$del]);
}
foreach (static::$forceSave as $value) {
$result[$value] = isset($this->$value)
? $this->$value: null;
}
return $result;
}
/**
* Devuelve el nombre de la clase actual aunque sea una clase extendida.
*
* @return string
* Devuelve el nombre de la clase actual.
*/
public static function className() : string {
return strtolower(substr(strrchr(get_called_class(), '\\'), 1));
}
/**
* Construye (a partir del nombre de la clase y el sufijo en static::$tableSufix)
* y/o develve el nombre de la tabla de la BD en la que se alojará o
* se aloja el objeto actual.
*
* @return string
*/
protected static function table() : string {
if (isset(static::$table))
return static::$table;
return static::className().static::$tableSufix;
}
/**
* Actualiza los valores en la BD con los valores del objeto actual.
*/
protected function update() {
$atts = $this->getVars();
foreach ($atts as $key => $value) {
if (isset($value)) {
$value = static::db()->real_escape_string($value);
if (in_array($key, $this->toNull))
$set[]="$key=NULL";
else
$set[]="$key='$value'";
} else {
if (in_array($key, $this->toNull))
$set[]="$key=NULL";
}
}
$table = static::table();
$pk = static::$primaryKey;
$pkv = $this->$pk;
$sql = "UPDATE $table SET ".join(', ', $set)." WHERE $pk='$pkv'";
static::query($sql);
}
/**
* Inserta una nueva fila en la base de datos a partir del
* objeto actual.
*/
protected function add() {
$db = static::db();
$atts = $this->getVars();
foreach ($atts as $key => $value) {
if (isset($value)) {
$into[] = "`$key`";
$values[] = "'".$db->real_escape_string($value)."'";
}
}
$table = static::table();
$sql = "INSERT INTO $table (".join(', ', $into).") VALUES (".join(', ', $values).")";
static::query($sql);
$pk = static::$primaryKey;
$this->$pk = $db->insert_id;
}
/**
* Revisa si el objeto a guardar es nuevo o no y según el resultado
* llama a update para actualizar o add para insertar una nueva fila.
*/
public function save() {
$pk = static::$primaryKey;
if (isset($this->$pk))
$this->update();
else
$this->add();
}
/**
* Elimina el objeto actual de la base de datos.
*/
public function delete() {
$atts = $this->getVars();
foreach ($atts as $key => $value) {
$value = static::db()->real_escape_string($value);
$set[]="$key='$value'";
}
$table = static::table();
$pk = static::$primaryKey;
$pkv = $this->$pk;
$sql = "DELETE FROM $table WHERE $pk='$pkv'";
static::query($sql);
}
/**
* Define SELECT en la sentencia SQL.
*
* @param array $columns
* Columnas que se selecionarán en la consulta SQL.
*
* @return ModelMySQL
*/
public static function select(array $columns) : ModelMySQL {
$db = static::db();
$select = [];
foreach($columns as $column) {
$select[] = $db->real_escape_string($column);
}
static::$querySelect['select'] = $select;
return new static();
}
/**
* Define FROM en la sentencia SQL.
*
* @param array $tables
* Tablas que se selecionarán en la consulta SQL.
*
* @return ModelMySQL
*/
public static function from(array $tables) : ModelMySQL {
$db = static::db();
$from = [];
foreach($tables as $table) {
$from[] = $db->real_escape_string($table);
}
static::$querySelect['from'] = join(', ', $from);
return new static();
}
/**
* Define el WHERE en la sentencia SQL.
*
* @param string $column
* La columna a comparar.
*
* @param string $operatorOrValue
* El operador o el valor a comparar como igual en caso de que $value no se defina.
*
* @param string $value
* (opcional) El valor el valor a comparar en la columna.
*
* @param bool $no_quote
* (opcional) Se usa cuando $value es una columna o un valor que no requiere comillas.
*
* @return ModelMySQL
*/
public static function where(string $column, string $operatorOrValue, string $value=null, bool $no_quote = false) : ModelMySQL {
if (is_null($value)) {
$value = $operatorOrValue;
$operatorOrValue = '=';
}
$value = static::db()->real_escape_string($value);
if ($no_quote)
static::$querySelect['where'] = "$column$operatorOrValue$value";
else
static::$querySelect['where'] = "$column$operatorOrValue'$value'";
return new static();
}
/**
* Define WHERE usando IN en la sentencia SQL.
*
* @param string $column
* La columna a comparar.
*
* @param array $arr
* Arreglo con todos los valores a comparar con la columna.
*
* @param bool $in
* Define si se tienen que comprobar negativa o positivamente.
*
* @return ModelMySQL
*/
public static function where_in(string $column, array $arr, bool $in = true) : ModelMySQL {
foreach($arr as $index => $value) {
$arr[$index] = static::db()->real_escape_string($value);
}
if ($in)
static::$querySelect['where'] = "$column IN (".join(', ',$arr).")";
else
static::$querySelect['where'] = "$column NOT IN (".join(', ',$arr).")";
return new static();
}
/**
* Define LEFT JOIN en la sentencia SQL.
*
* @param string $table
* Tabla que se va a juntar a la del objeto actual.
*
* @param string $columnA
* Columna a comparar para hacer el join.
*
* @param string $operatorOrColumnB
* Operador o columna a comparar como igual para hacer el join en caso de que $columnB no se defina.
*
* @param string $columnB
* (opcional) Columna a comparar para hacer el join.
*
* @return ModelMySQL
*/
public static function leftJoin(string $table, string $columnA, string $operatorOrColumnB, string $columnB = null) : ModelMySQL {
if (is_null($columnB)) {
$columnB = $operatorOrColumnB;
$operatorOrColumnB = '=';
}
$columnA = static::db()->real_escape_string($columnA);
$columnB = static::db()->real_escape_string($columnB);
static::$querySelect['leftJoin'] .= ' LEFT JOIN ' . $table . ' ON ' . "$columnA$operatorOrColumnB$columnB";
return new static();
}
/**
* Define RIGHT JOIN en la sentencia SQL.
*
* @param string $table
* Tabla que se va a juntar a la del objeto actual.
*
* @param string $columnA
* Columna a comparar para hacer el join.
*
* @param string $operatorOrColumnB
* Operador o columna a comparar como igual para hacer el join en caso de que $columnB no se defina.
*
* @param string $columnB
* (opcional) Columna a comparar para hacer el join.
*
* @return ModelMySQL
*/
public static function rightJoin(string $table, string $columnA, string $operatorOrColumnB, string $columnB = null) : ModelMySQL {
if (is_null($columnB)) {
$columnB = $operatorOrColumnB;
$operatorOrColumnB = '=';
}
$columnA = static::db()->real_escape_string($columnA);
$columnB = static::db()->real_escape_string($columnB);
static::$querySelect['rightJoin'] .= ' RIGHT JOIN ' . $table . ' ON ' . "$columnA$operatorOrColumnB$columnB";
return new static();
}
/**
* Define INNER JOIN en la sentencia SQL.
*
* @param string $table
* Tabla que se va a juntar a la del objeto actual.
*
* @param string $columnA
* Columna a comparar para hacer el join.
*
* @param string $operatorOrColumnB
* Operador o columna a comparar como igual para hacer el join en caso de que $columnB no se defina.
*
* @param string $columnB
* (opcional) Columna a comparar para hacer el join.
*
* @return ModelMySQL
*/
public static function innerJoin(string $table, string $columnA, string $operatorOrColumnB, string $columnB = null) : ModelMySQL {
if (is_null($columnB)) {
$columnB = $operatorOrColumnB;
$operatorOrColumnB = '=';
}
$columnA = static::db()->real_escape_string($columnA);
$columnB = static::db()->real_escape_string($columnB);
static::$querySelect['innerJoin'] .= ' INNER JOIN ' . $table . ' ON ' . "$columnA$operatorOrColumnB$columnB";
return new static();
}
/**
* Define AND en la sentencia SQL (se puede anidar).
*
* @param string $column
* La columna a comparar.
*
* @param string $operatorOrValue
* El operador o el valor a comparar como igual en caso de que $value no se defina.
*
* @param string $value
* (opcional) El valor el valor a comparar en la columna.
*
* @param bool $no_quote
* (opcional) Se usa cuando $value es una columna o un valor que no requiere comillas.
*
* @return ModelMySQL
*/
public static function and(string $column, string $operatorOrValue, string $value=null, bool $no_quote = false) : ModelMySQL {
if (is_null($value)) {
$value = $operatorOrValue;
$operatorOrValue = '=';
}
$value = static::db()->real_escape_string($value);
if ($no_quote)
static::$querySelect['AndOr'] .= " AND $column$operatorOrValue$value";
else
static::$querySelect['AndOr'] .= " AND $column$operatorOrValue'$value'";
return new static();
}
/**
* Define OR en la sentencia SQL (se puede anidar).
*
* @param string $column
* La columna a comparar.
*
* @param string $operatorOrValue
* El operador o el valor a comparar como igual en caso de que $value no se defina.
*
* @param string $value
* (opcional) El valor el valor a comparar en la columna.
*
* @param bool $no_quote
* (opcional) Se usa cuando $value es una columna o un valor que no requiere comillas.
*
* @return ModelMySQL
*/
public static function or(string $column, string $operatorOrValue, string $value=null, bool $no_quote = false) : ModelMySQL {
if (is_null($value)) {
$value = $operatorOrValue;
$operatorOrValue = '=';
}
$value = static::db()->real_escape_string($value);
if ($no_quote)
static::$querySelect['AndOr'] .= " OR $column$operatorOrValue$value";
else
static::$querySelect['AndOr'] .= " OR $column$operatorOrValue'$value'";
return new static();
}
/**
* Define GROUP BY en la sentencia SQL.
*
* @param array $arr
* Columnas por las que se agrupará.
*
* @return ModelMySQL
*/
public static function groupBy(array $arr) : ModelMySQL {
static::$querySelect['groupBy'] = join(', ', $arr);
return new static();
}
/**
* Define LIMIT en la sentencia SQL.
*
* @param int $initial
* @param int $final
*
* @return ModelMySQL
*/
public static function limit(int $initial, int $final = 0) : ModelMySQL {
if ($final==0)
static::$querySelect['limit'] = $initial;
else
static::$querySelect['limit'] = $initial.', '.$final;
return new static();
}
/**
* Define ORDER BY en la sentencia SQL.
*
* @param string $value
* Columna por la que se ordenará.
*
* @param string $order
* (opcional) Define si el orden será de manera ascendente (ASC),
* descendente (DESC) o aleatorio (RAND).
*
* @return ModelMySQL
*/
public static function orderBy(string $value, string $order = 'ASC') : ModelMySQL {
if ($value == "RAND") {
static::$querySelect['orderBy'] = 'RAND()';
return new static();
}
$value = static::db()->real_escape_string($value);
if (!(strtoupper($order) == 'ASC' || strtoupper($order) == 'DESC'))
$order = 'ASC';
static::$querySelect['orderBy'] = $value.' '.$order;
return new static();
}
/**
* Retorna la cantidad de filas que hay en un query.
*
* @param bool $resetQuery
* (opcional) Indica si el query debe reiniciarse o no (por defecto es true).
*
* @param bool $useLimit
* (opcional) Permite usar limit para estabecer un máximo inical y final para contar. Requiere que se haya definido antes el límite (por defecto en false).
*
* @return int
*/
public static function count(bool $resetQuery = true, bool $useLimit = false) : int {
if (!$resetQuery)
$backup = [
'select' => static::$querySelect['select'],
'sql_calc_found_rows' => static::$querySelect['sql_calc_found_rows'],
'limit' => static::$querySelect['limit'],
'orderBy' => static::$querySelect['orderBy']
];
if ($useLimit && static::$querySelect['limit'] != '') {
static::$querySelect['select'] = ['1'];
static::$querySelect['sql_calc_found_rows'] = false;
static::$querySelect['orderBy'] = '';
$sql = 'SELECT COUNT(1) AS quantity FROM ('.static::buildQuery($resetQuery).') AS counted';
$queryResult = static::query($sql)->fetch_assoc();
$result = $queryResult['quantity'];
} else {
static::$querySelect['select'] = ['1'];
static::$querySelect['sql_calc_found_rows'] = true;
static::$querySelect['limit'] = '1';
static::$querySelect['orderBy'] = '';
$sql = static::buildQuery($resetQuery);
static::query($sql);
$result = static::found_row();
}
if (!$resetQuery) {
static::$querySelect['select'] = $backup['select'];
static::$querySelect['sql_calc_found_rows'] = $backup['sql_calc_found_rows'];
static::$querySelect['limit'] = $backup['limit'];
static::$querySelect['orderBy'] = $backup['orderBy'];
}
return $result;
}
/**
* Retorna las filas contadas en el último query.
*
* @return int
*/
public static function found_row() : int {
$result = static::query('SELECT FOUND_ROWS() AS quantity')->fetch_assoc();
return $result['quantity'];
}
/**
* Habilita el conteo de todos las coincidencias posibles incluso usando limit.
*
* @return ModelMySQL
*/
public static function sql_calc_found_rows() : ModelMySQL {
static::$querySelect['sql_calc_found_rows'] = true;
return new static();
}
/**
* Obtiene una instancia según su primary key (generalmente id).
* Si no encuentra una instancia, devuelve nulo.
*
* @param mixed $id
* @return mixed
*/
public static function getById($id) {
return static::where(static::$primaryKey, $id)->getFirst();
}
/**
* Realiza una búsqueda en la tabla de la instancia actual.
*
* @param string $search
* Contenido a buscar.
*
* @param array $in
* (opcional) Columnas en las que se va a buscar (null para buscar en todas).
*
* @return ModelMySQL
*/
public static function search(string $search, array $in = null) : ModelMySQL {
if ($in == null) {
$className = get_called_class();
$in = array_keys((new $className())->getVars());
}
$db = static::db();
$search = $db->real_escape_string($search);
$where = [];
foreach($in as $row) {
$where[] = "$row LIKE '%$search%'";
}
if (static::$querySelect['where']=='')
static::$querySelect['where'] = join(' OR ', $where);
else
static::$querySelect['where'] = static::$querySelect['where'] .' AND ('.join(' OR ', $where).')';
return new static();
}
/**
* Obtener los resultados de la consulta SQL.
*
* @param bool $resetQuery
* (opcional) Indica si el query debe reiniciarse o no (por defecto es true).
*
* @return array
* Contiene un arreglo de instancias de la clase actual.
*/
public static function get(bool $resetQuery = true) : array { // Devuelve array vacío si no encuentra nada.
$sql = static::buildQuery($resetQuery);
$result = static::query($sql);
$instances = [];
while ($row = $result->fetch_assoc()) {
$instances[] = static::getInstance($row);
}
return $instances;
}
/**
* El primer elemento de la consulta SQL.
*
* @param bool $resetQuery
* (opcional) Indica si el query debe reiniciarse o no (por defecto es true).
*
* @return mixed
* Puede retornar un objeto ModelMySQL o null.
*/
public static function getFirst(bool $resetQuery = true) { // Devuelve null si no encuentra nada.
static::limit(1);
$instances = static::get($resetQuery);
return empty($instances) ? null : $instances[0];
}
/**
* Obtener todos los elementos del la tabla de la instancia actual.
*
* @return array
* Contiene un arreglo de instancias de la clase actual.
*/
public static function all() : array {
$sql = 'SELECT * FROM '.static::table();
$result = static::query($sql);
$instances = [];
while ($row = $result->fetch_assoc()) {
$instances[] = static::getInstance($row);
}
return $instances;
}
/**
* Permite definir como nulo el valor de un atributo.
* Sólo funciona para actualizar un elemento de la BD, no para insertar.
*
* @trows \Exception
* Devolverá un error en caso de usarse en un insert.
*
* @param array $atts
*/
public function setNull(array $atts) {
if (!isset($this->id))
throw new \Exception(
"\nEl método setNull sólo funciona para actualizar, no al insertar."
);
foreach ($atts as $att) {
if (!in_array($att, $this->toNull))
$this->toNull[] = $att;
}
}
}
?>

View File

@@ -1,39 +1,59 @@
<?php
/**
* Neuron - DuckBrain
*
* Neuron, sirve para crear un objeto que alojará valores, pero
* además tiene la característica especial de que al intentar
* acceder a un atributo que no está definido devolerá nulo en
* lugar de generar un error php notice que indica que se está
* intentando acceder a un valor no definido.
*
* El constructor recibe un objeto o arreglo con los valores que
* sí estarán definidos.
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
namespace Libs;
class Neuron {
use AllowDynamicProperties;
private $data;
/**
* Neuron - DuckBrain
*
* Neuron, serves to create an object that will hold values.
* In addition, it has the special characteristic that when trying
* to access an undefined property, it will return null instead
* of generating a notice (PHP notice) for an undefined variable or property.
*
* The constructor accepts an object or an array containing the
* values that will be defined.
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
#[AllowDynamicProperties]
class Neuron
{
/**
* Constructor
*
* @param array $data Data to initialize the Neuron with. Can be an array or an object.
*/
public function __construct(...$data)
{
if (
count($data) === 1 &&
isset($data[0]) &&
(is_array($data[0]) ||
is_object($data[0]))
) {
$data = $data[0];
}
public function __construct($data = []){
$this->data = (array) $data;
foreach ($data as $key => $value) {
$this->{$key} = $value;
}
}
public function __isset($index) {
return isset($this->data[$index]);
}
public function __get($index){
return (isset($this->data[$index]) && $this->data[$index] != '')
? $this->data[$index] : null;
/**
* Magic method __get
*
* This method is called when an undefined property is accessed.
* It returns null instead of triggering an E_NOTICE.
*
* @param string $index The name of the property being accessed.
* @return null Always returns null for undefined properties.
*/
public function __get(string $index)
{
return null;
}
}
?>

180
src/Libs/Request.php Normal file
View File

@@ -0,0 +1,180 @@
<?php
namespace Libs;
use Exception;
/**
* Request - DuckBrain
*
* Complementary library to the Router library.
* Contains the basic body of the http request (POST, GET, JSON, etc).
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
class Request extends Neuron
{
public Neuron $get;
public Neuron $post;
public Neuron $put;
public Neuron $patch;
public Neuron $delete;
public Neuron $json;
public Neuron $params;
public string $path;
public string $error;
public string $body;
/**
* __construct
*/
public function __construct()
{
$this->path = Router::currentPath();
$this->get = new Neuron($_GET);
$this->post = new Neuron($_POST);
$this->put = new Neuron();
$this->patch = new Neuron();
$this->delete = new Neuron();
$this->params = Router::$params ?? new Neuron();
$this->body = file_get_contents("php://input");
$contentType = isset($_SERVER["CONTENT_TYPE"]) ? trim($_SERVER["CONTENT_TYPE"]) : '';
if ($contentType === "application/json") {
$this->json = new Neuron(
(object) json_decode(trim($this->body), false)
);
} else {
$this->json = new Neuron();
if (
in_array($_SERVER['REQUEST_METHOD'], ['PUT', 'PATCH', 'DELETE']) &&
preg_match('/^[^;?\/:@&=+$,]{1,255}[=]/', $this->body, $matches)
) {
// With the regular expression, we verify that it is a valid
// http query string and avoid memory errors in case
// the body contains something larger than that.
parse_str($this->body, $input_vars);
$this->{strtolower($_SERVER['REQUEST_METHOD'])} = new Neuron($input_vars);
}
}
// Run configured validations
$this->validate();
}
/**
* Starts the configured validation.
*
* 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(): void
{
$actual = match ($_SERVER['REQUEST_METHOD']) {
'POST', 'PUT', 'PATCH', 'DELETE' => $this->{strtolower($_SERVER['REQUEST_METHOD'])},
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 (
Validator::validateList(static::paramRules(), $this->params) &&
Validator::validateList(static::getRules(), $this->get) &&
Validator::validateList(static::rules(), $body)
) {
return;
}
$error = Validator::message(
Validator::$lastFailed,
static::messages(),
static::attributes()
);
static::onInvalid($error);
}
/**
* Rules for the current method.
*
* @return array
*/
public function rules(): array
{
return [];
}
/**
* Rules for URL parameters.
*
* @return array
*/
public function paramRules(): array
{
return [];
}
/**
* Rules for GET parameters.
*
* @return array
*/
public function getRules(): array
{
return [];
}
/**
* Error messages in case a validation fails.
*
* @return array
*/
public function messages(): array
{
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.
*
* 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
*
* @return never
* @throws Exception
*/
public function onInvalid(string $error): never
{
throw new Exception($error, 422);
}
}

View File

@@ -1,48 +1,141 @@
<?php
/**
* Router - DuckBrain
*
* Librería de Enrrutador.
* Depende de manera forzada de que la constante ROOT_DIR esté definida
* y de manera optativa de que la constante SITE_URL lo esté también.
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
namespace Libs;
class Router {
private static $get = [];
private static $post = [];
private static $put = [];
private static $delete = [];
private static $last;
public static $notFoundCallback = 'Libs\Router::defaultNotFound';
/**
* Router - DuckBrain
*
* Router library.
* It strictly depends on the ROOT_DIR constant being defined
* and optionally on the SITE_URL constant being defined.
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
class Router
{
private static array $get = [];
private static array $post = [];
private static array $put = [];
private static array $patch = [];
private static array $delete = [];
public static function defaultNotFound () {
/**
* 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].
* Used for chaining methods like middleware() or reconfigure().
*
* @var array $last
*/
private static array $last;
/**
* Stores the parameters extracted from the current matching route.
*
* @var Neuron $params
*/
public static Neuron $params;
/**
* The callback function to be executed when no route matches.
*
* @var callable $notFoundCallback
*/
public static $notFoundCallback = 'Libs\Router::defaultNotFound';
/**
* Default callback function for when
* the route is not found.
*
* @return void
*/
public static function defaultNotFound(): void
{
header("HTTP/1.0 404 Not Found");
echo '<h2 style="text-align: center;margin: 25px 0px;">Error 404 - Página no encontrada</h2>';
echo '<h2 style="text-align: center;margin: 25px 0px;">Error 404 - Page Not Found</h2>';
}
private function __construct() {}
/**
* 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';
/*
* Parsea para deectar las pseudovariables (ej: {variable})
/**
* 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
*/
private function __construct()
{
}
/**
* Parses to detect pseudovariables (e.g., {variable})
*
* @param string $path
* Ruta con pseudovariables.
* Route with pseudovariables.
*
* @param mixed $callback
* Callback que será llamado cuando la ruta configurada en $path coincida.
* @param callable $callback
* Callback that will be called when the route configured in $path matches.
*
* @return array
* Arreglo con 2 índices:
* path - Contiene la ruta con las pseudovariables reeplazadas por expresiones regulares.
* callback - Contiene el callback en formato Namespace\Clase::Método.
* Array with 3 indices:
* path - Contains the route with pseudovariables replaced by regular expressions.
* callback - Contains the callback in Namespace\Class::Method format.
* paramNames - An array of parameter names found in the path.
*/
private static function parse(string $path, $callback) : array {
private static function parse(string $path, callable $callback): array
{
preg_match_all('/{(\w+)}/s', $path, $matches, PREG_PATTERN_ORDER);
$paramNames = $matches[1];
@@ -50,76 +143,75 @@ class Router {
$path = preg_replace(
['/\\\{\w+\\\}/s'],
['([^\/]+)'],
$path);
if (!is_callable($callback)) {
$callback = 'Controllers\\'.$callback;
}
$path
);
return [
'path' => $path,
'callback' => [$callback],
'paramNames' => $paramNames
'paramNames' => $paramNames,
];
}
/*
* Devuelve el ruta base o raiz del proyecto sobre la que trabajará el router.
/**
* Returns the base or root path of the project on which the router will work.
*
* Ej: Si la url del sistema está en "https://ejemplo.com/duckbrain"
* entonces la ruta base sería "/duckbrain"
* Ex: If the system URL is "https://example.com/duckbrain"
* then the base path would be "/duckbrain"
*
* @return string
*/
public static function basePath() : string {
if (defined('SITE_URL'))
return parse_url(SITE_URL, PHP_URL_PATH);
public static function basePath(): string
{
if (defined('SITE_URL') && !empty(SITE_URL)) {
return rtrim(parse_url(SITE_URL, PHP_URL_PATH), '/') . '/';
}
return str_replace($_SERVER['DOCUMENT_ROOT'], '/', ROOT_DIR);
}
/*
* Redirije a una ruta relativa interna.
/**
* Redirects to an internal relative path.
*
* @param string $path
* La ruta relativa a la ruta base.
* The path relative to the base path.
*
* Ej: Si nuesto sistema está en "https://ejemplo.com/duckbrain"
* llamamos a Router::redirect('/docs'), entonces seremos
* redirigidos a "https://ejemplo.com/duckbrain/docs".
* Ex: If our system is at "https://example.com/duckbrain"
* and we call Router::redirect('/docs'), we will be
* redirected to "https://example.com/duckbrain/docs".
* @return void
*/
public static function redirect(string $path) {
header('Location: '.static::basePath().substr($path,1));
public static function redirect(string $path): void
{
header('Location: ' . static::basePath() . ltrim($path, '/'));
}
/**
* Añade un middleware a la última ruta usada.
* Solo se puede usar un middleware a la vez.
* Adds a middleware to the last used route.
* Only one middleware can be added at a time.
*
* @param mixed $callback
* @param int $prioriry
* @param callable $callback
* @param int|null $priority Optional priority for the middleware execution order.
*
* @return Router
* Devuelve un enlace estático.
* @return static
* Returns the current instance.
*/
public static function middleware($callback, int $priority = null) : Router {
if (!isset(static::$last))
public static function middleware(callable $callback, ?int $priority = null): static
{
if (!isset(static::$last)) {
return new static();
}
$method = static::$last[0];
$index = static::$last[1];
if (!is_callable($callback)) {
$callback = 'Middlewares\\'.$callback;
if (isset($priority) && $priority <= 0) {
$priority = 1;
}
if (isset($priority) && $priority <= 0)
$priority = 1;
if (is_null($priority) || $priority >= count(static::$$method[$index]['callback']))
if (is_null($priority) || $priority >= count(static::$$method[$index]['callback'])) {
static::$$method[$index]['callback'][] = $callback;
else {
} else {
static::$$method[$index]['callback'] = array_merge(
array_slice(static::$$method[$index]['callback'], 0, $priority),
[$callback],
@@ -131,185 +223,247 @@ class Router {
}
/**
* @return Neuron
* Devuelve un objeto que contiene los atributos:
* post - Donde se encuentran los valores enviados por $_POST.
* get - Donde se encuentran los valores enviados por $_GET.
* json - Donde se encuentran los valores JSON enviados en el body.
*
*/
private static function getReq() : Neuron {
$req = new Neuron();
$req->get = new Neuron($_GET);
$req->post = new Neuron($_POST);
$req->json = new Neuron(static::get_json());
$req->params = new Neuron();
$req->path = static::currentPath();
return $req;
}
/**
* @return object
* Devuelve un objeto con los datos recibidos en JSON.
*/
private static function get_json() : object {
$contentType = isset($_SERVER["CONTENT_TYPE"]) ? trim($_SERVER["CONTENT_TYPE"]) : '';
if ($contentType === "application/json") {
return json_decode(trim(file_get_contents("php://input")));
}
return (object) '';
}
/**
* Define los routers para el método GET.
*
* @param string $path
* Ruta con pseudovariables.
*
* @param mixed $callback
* Callback que será llamado cuando la ruta configurada en $path coincida.
*
* @return Router
* Devuelve un enlace estático.
*/
public static function get(string $path, $callback) {
static::$get[] = static::parse($path, $callback);
static::$last = ['get', count(static::$get)-1];
return new static();
}
/**
* Define los routers para el método POST.
*
* @param string $path
* Ruta con pseudovariables.
*
* @param mixed $callback
* Callback que será llamado cuando la ruta configurada en $path coincida.
*
* @return Router
* Devuelve un enlace estático.
*/
public static function post(string $path, $callback) : Router {
static::$post[] = static::parse($path, $callback);
static::$last = ['post', count(static::$post)-1];
return new static();
}
/**
* Define los routers para el método PUT.
*
* @param string $path
* Ruta con pseudovariables.
*
* @param mixed $callback
* Callback que será llamado cuando la ruta configurada en $path coincida.
*
* @return Router
* Devuelve un enlace estático
*/
public static function put(string $path, $callback) : Router {
static::$put[] = static::parse($path, $callback);
static::$last = ['put', count(static::$put)-1];
return new static();
}
/**
* Define los routers para el método DELETE.
*
* @param string $path
* Ruta con pseudovariables
* 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
* Callback que será llamado cuando la ruta configurada en $path coincida.
*
* @return static
* Devuelve un enlace estático
*/
public static function delete(string $path, $callback) : Router {
static::$delete[] = static::parse($path, $callback);
static::$last = ['delete', count(static::$delete)-1];
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();
}
/**
* Devuelve la ruta actual.
* Reconfigures the final callback of the last route.
*
* @param callable $callback
*
* @return static
*/
public static function reconfigure(callable $callback): static
{
if (empty(static::$last)) {
return new static();
}
$method = static::$last[0];
$index = static::$last[1];
static::$$method[$index]['callback'][0] = $callback;
return new static();
}
/**
* Configures any method for all routes.
*
* If no callback is received, it searches for the current route
* and only sets the route as the last configured one
* provided it has been configured previously.
*
* @param string $method
* HTTP method.
* @param string $path
* Route with pseudovariables.
* @param callable|null $callback
*
* @return static
* Returns the current instance.
*/
public static function configure(string $method, string $path, ?callable $callback = null): static
{
if (is_null($callback)) {
$path = preg_quote($path, '/');
$path = preg_replace(
['/\\\{\w+\\\}/s'],
['([^\/]+)'],
$path
);
foreach (static::$$method as $index => $router) {
if ($router['path'] == $path) {
static::$last = [$method, $index];
break;
}
}
return new static();
}
static::$$method[] = static::parse($path, $callback);
static::$last = [$method, count(static::$$method) - 1];
foreach (static::$withMiddlewares as $middleware) {
static::middleware($middleware);
}
return new static();
}
/**
* Defines routers for the GET method.
*
* @param string $path
* Route with pseudovariables.
* @param callable|null $callback
* Callback that will be called when the route configured in $path matches.
*
* @return static
* Returns the current instance.
*/
public static function get(string $path, ?callable $callback = null): static
{
return static::configure('get', $path, $callback);
}
/**
* Defines routers for the POST method.
*
* @param string $path
* Route with pseudovariables.
* @param callable|null $callback
* Callback that will be called when the route configured in $path matches.
*
* @return static
* Returns the current instance.
*/
public static function post(string $path, ?callable $callback = null): static
{
return static::configure('post', $path, $callback);
}
/**
* Defines routers for the PUT method.
*
* @param string $path
* Route with pseudovariables.
* @param callable|null $callback
* Callback that will be called when the route configured in $path matches.
*
* @return static
* Returns the current instance
*/
public static function put(string $path, ?callable $callback = null): static
{
return static::configure('put', $path, $callback);
}
/**
* Defines routers for the PATCH method.
*
* @param string $path
* Route with pseudovariables.
* @param callable|null $callback
* Callback that will be called when the route configured in $path matches.
*
* @return static
* Returns the current instance
*/
public static function patch(string $path, ?callable $callback = null): static
{
return static::configure('patch', $path, $callback);
}
/**
* Defines routers for the DELETE method.
*
* @param string $path
* Route with pseudovariables
* @param callable|null $callback
* Callback that will be called when the route configured in $path matches.
*
* @return static
* Returns the current instance
*/
public static function delete(string $path, ?callable $callback = null): static
{
return static::configure('delete', $path, $callback);
}
/**
* Returns the current path, taking the DuckBrain installation path as the root.
*
* @return string
*/
public static function currentPath() : string {
return preg_replace('/'.preg_quote(static::basePath(), '/').'/',
'/', strtok($_SERVER['REQUEST_URI'], '?'), 1);
public static function currentPath(): string
{
return preg_replace(
'/' . preg_quote(static::basePath(), '/') . '/',
'/',
strtok($_SERVER['REQUEST_URI'], '?'),
1
);
}
/**
* Aplica los routers.
* Applies the route configuration.
*
* Este método ha de ser llamado luego de que todos los routers hayan sido configurados.
* 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).
*
* En caso que la ruta actual coincida con un router configurado, se comprueba si hay middleware; Si hay
* middleware, se enviará el callback y los datos de la petición como un Neuron. Caso contrario, se enviarán
* los datos directamente al callback.
* @param string|null $path (optional) Path to use. If not defined, it detects the current path.
*
* Con middleware:
* $middleware($callback, $req)
*
* Sin middleware:
* $callback($req)
*
* $req es una instancia de Neuron que tiene los datos de la petición.
*
* Si no la ruta no coincide con ninguna de las rutas configuradas, ejecutará el callback $notFoundCallback
* @return void
*/
public static function apply() {
$path = static::currentPath();
$routers = [];
switch ($_SERVER['REQUEST_METHOD']){ // Según el método selecciona un arreglo de routers configurados
case 'POST':
$routers = static::$post;
break;
case 'PUT':
$routers = static::$put;
break;
case 'DELETE':
$routers = static::$delete;
break;
default:
$routers = static::$get;
break;
}
public static function apply(?string $path = null): void
{
try {
$path = $path ?? static::currentPath();
$routers = match ($_SERVER['REQUEST_METHOD']) { // Selects an array of routers based on the method
'POST' => static::$post,
'PUT' => static::$put,
'PATCH' => static::$patch,
'DELETE' => static::$delete,
default => static::$get
};
$req = static::getReq();
foreach ($routers as $router) { // Checks all routers to see if they match the current path
if (preg_match_all('/^' . $router['path'] . '\/?$/si', $path, $matches, PREG_PATTERN_ORDER)) {
unset($matches[0]);
foreach ($routers as $router) { // revisa todos los routers para ver si coinciden con la ruta actual
if (preg_match_all('/^'.$router['path'].'\/?$/si',$path, $matches, PREG_PATTERN_ORDER)) {
unset($matches[0]);
// Comprobando pseudo variables en la ruta
if (isset($matches[1])) {
foreach ($matches as $index => $match) {
$paramName = $router['paramNames'][$index-1];
$req->params->$paramName = urldecode($match[0]);
// Checking and storing the variable parameters of the route
if (isset($matches[1])) {
static::$params = new Neuron();
foreach ($matches as $index => $match) {
$paramName = $router['paramNames'][$index - 1];
static::$params->{$paramName} = urldecode($match[0]);
}
}
// Processes the callback queue
foreach (array_reverse($router['callback']) as $callback) {
$data = Synapsis::resolve($callback);
}
// By default, prints as JSON if something is returned
if (isset($data)) {
header('Content-Type: application/json');
print(json_encode($data));
}
return;
}
// Llamar al último callback configurado
$next = array_pop($router['callback']);
$req->next = $router['callback'];
$data = call_user_func_array($next, [$req]);
if (isset($data)) {
header('Content-Type: application/json');
print(json_encode($data));
}
return;
}
}
// Si no hay router que coincida llamamos a $notFoundCallBack
call_user_func_array(static::$notFoundCallback, [$req]);
// If no router matches, call $notFoundCallBack
Synapsis::resolve(static::$notFoundCallback);
} catch (\Throwable $exception) {
Synapsis::resolve(static::$exceptionCallback, ['exception' => $exception]);
}
}
}
?>

179
src/Libs/Synapsis.php Normal file
View File

@@ -0,0 +1,179 @@
<?php
namespace Libs;
use Closure;
use Exception;
use ReflectionClass;
use ReflectionFunction;
use ReflectionMethod;
use ReflectionParameter;
/**
* Synapsis - DuckBrain
*
* Class responsible for dependency resolution and injection.
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
class Synapsis
{
/**
* @var array<class-string, class-string> $bindings
*/
private static array $bindings = [];
/**
* @var array<class-string, mixed> $instances
*/
private static array $instances = [];
/**
* Binds an interface to a class to instantiate it instead.
*
* @param class-string $interfaceName
* @param class-string $className
*
* @return void
* @throws Exception If the interface or class does not exist.
*/
public static function bind(string $interfaceName, string $className): void
{
if (interface_exists($interfaceName) && class_exists($className)) {
static::$bindings[$interfaceName] = $className;
return;
}
throw new Exception('Error at binding non existant Interface or ClassName.');
}
/**
* Binds multiple interfaces to multiple classes in bulk.
*
* @param array<class-string, class-string> $bindings
*
* @return void
*/
public static function bulkBind(array $bindings): void
{
foreach ($bindings as $interfaceName => $className) {
static::bind($interfaceName, $className);
}
}
/**
* Resolves and injects dependencies for a callable and returns its result.
*
* @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
* @throws Exception If an unhandled callable type is provided.
*/
public static function resolve(callable $action, array $named = []): mixed
{
if ($action instanceof Closure) { // If it's an anonymous function
$reflectionCallback = new ReflectionFunction($action);
} else { // Array or string with the method
if (is_string($action)) {
$action = preg_split('/::/', $action);
}
// Check if it's a method or just a function
if (count($action) == 2) {
$reflectionCallback = new ReflectionMethod($action[0], $action[1]);
} else {
$reflectionCallback = new ReflectionFunction($action[0]);
}
}
// Get the parameters
return call_user_func_array(
$action,
static::resolveParameterValues($reflectionCallback->getParameters(), $named)
);
}
/**
* Resolves and injects dependencies for a class and returns its result.
*
* @param class-string $className
*
* @return mixed
* @throws Exception If a binding is missing for an interface or the class does not exist.
*/
public static function &resolveInstance(string $className): mixed
{
if (interface_exists($className)) {
if (isset(static::$bindings[$className])) {
$className = static::$bindings[$className];
} else {
throw new Exception("Missing binding for interface: {$className}");
}
}
if (!class_exists($className)) {
throw new Exception("Cannot resolve: {$className}");
}
if (!isset(static::$instances[$className])) {
// If the instance has not been resolved before, resolve it
$reflectionClass = new ReflectionClass($className);
$constructor = $reflectionClass->getConstructor();
static::$instances[$className] = new $className(
...static::resolveParameterValues($constructor?->getParameters() ?? [])
);
}
return static::$instances[$className];
}
/**
* Resolves parameter values by injecting dependencies.
*
* @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>
* @throws Exception If a primitive parameter does not have a default value.
*/
public static function resolveParameterValues(array $parameters, array $named = []): array
{
$values = [];
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
$values[] = $parameter->getDefaultValue();
continue;
}
$paramType = $parameter->getType();
if ($paramType === null) {
// If no type is declared, and it's not optional, we cannot resolve it.
throw new Exception(
"Untyped parameter '{$parameter}' expect at least a default value or must be optional."
);
}
if ($paramType->isBuiltin()) {
throw new Exception("Primitive parameter '{$parameter->getName()}' expects at least a default value.");
}
$values[] = static::resolveInstance($paramType->getName());
}
return $values;
}
}

1255
src/Libs/Validator.php Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,38 +1,164 @@
<?php
/**
* View - DuckBrain
*
* Manejador de vistas simplificado.
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
namespace Libs;
class View {
/**
* View - DuckBrain
*
* Simplified view handler.
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
class View extends Neuron
{
/**
* Includes the file.
*
* @param string $viewName Relative path and filename without extension.
* @param string|null $viewPath (Optional) Path where the view file is located.
* @param string $extension (Optional) File extension.
*
* @return void
*/
protected function include(
string $viewName,
?string $viewPath = null,
string $extension = 'php'
): void {
if (
isset($viewPath) &&
file_exists("$viewPath$viewName.$extension")
) {
include("$viewPath$viewName.$extension");
return;
}
include(ROOT_CORE . "/Views/$viewName.$extension");
}
/**
* Función que "renderiza" las vistas
* Function that "renders" views.
*
* @param string $viewName
* Ruta relativa y el nommbre sin extensión del archivo ubicado en src/Views
* @param string $viewName Relative path and filename without extension.
* @param array|Neuron $params (Optional) Array that can be used in the view
* via $view ($param['index'] would be used as: $view->index).
* @param string|null $viewPath (Optional) Path where the view is located. If the
* view is not found in this path, the default path "src/Views/" will be used.
* @param string $extension (Optional) File extension.
*
* @param array $params
* (opcional) Arreglo que podrá ser usado en la vista mediante $view ($param['index'] se usaría así: $view->index)
*
* @param string $viewPath
* (opcional) Ruta donde se encuentra la vista. En caso de que la vista no se encuentre en esa ruta, se usará la ruta por defecto "src/Views/".
* @return void
*/
public static function render(string $viewName, array $params = [], string $viewPath = null) {
$view = new Neuron($params);
unset($params);
public static function render(
string $viewName,
array|Neuron $params = [],
?string $viewPath = null,
string $extension = 'php'
): void {
$instance = new View($params);
$instance->html($viewName, $viewPath, $extension);
}
if (isset($viewPath) && file_exists($viewPath.$viewName.'.php'))
return include($viewPath.$viewName.'.php');
/**
* Renders HTML views.
*
* @param string $viewName Relative path and filename without extension located in src/Views.
* @param string|null $viewPath (Optional) Path where the view is located. If the view is not
* found in this path, the default path "src/Views/" will be used.
* @param string $extension (Optional) File extension.
*
* @return void
*/
public function html(
string $viewName,
?string $viewPath = null,
string $extension = 'php'
): void {
$this->include(
$viewName,
$viewPath,
$extension
);
}
include(ROOT_DIR.'/src/Views/'.$viewName.'.php');
/**
* Renders CSS code.
*
* @param string $viewName Relative path and filename without extension located in src/Views.
* @param string|null $viewPath (Optional) Path where the view is located. If the view is not
* found in this path, the default path "src/Views/" will be used.
* @param string $extension (Optional) File extension.
*
* @return void
*/
public function css(
string $viewName,
?string $viewPath = null,
string $extension = 'css'
): void {
header("Content-type: text/css");
$this->include($viewName, $viewPath, $extension);
}
/**
* Renders Javascript code.
*
* @param string $viewName Relative path and filename without extension located in src/Views.
* @param string|null $viewPath (Optional) Path where the view is located. If the view is not
* found in this path, the default path "src/Views/" will be used.
* @param string $extension (Optional) File extension.
*
* @return void
*/
public function js(
string $viewName,
?string $viewPath = null,
string $extension = 'js'
): void {
header("Content-type: application/javascript");
$this->include($viewName, $viewPath, $extension);
}
/**
* Prints data as JSON.
*
* @param object|array $data Object or array to be printed as JSON.
*
* @return void
*/
public function json(object|array $data): void
{
header('Content-Type: application/json; charset=utf-8');
print(json_encode($data));
}
/**
* Prints data as plain text.
*
* @param string $txt Text content.
*
* @return void
*/
public function text(string $txt): void
{
header('Content-Type: text/plain; charset=utf-8');
print($txt);
}
/**
* Attempts to return the absolute URL from a relative path.
*
* @param string $path
*
* @return string
*/
public static function route(string $path = '/'): string
{
if (defined('SITE_URL') && !empty(SITE_URL)) {
return rtrim(SITE_URL, '/') . '/' . ltrim($path, '/');
}
return $path;
}
}
?>

View File

@@ -0,0 +1,55 @@
<?php
namespace Tests\Factories;
use Faker\Factory as FakerFactory;
use Faker\Generator;
/**
* Factory - DuckBrain testing
*
* Clase base para crear fábricas de datos de prueba. Cada subclase
* define los atributos por defecto de un modelo y construye su
* instancia.
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
abstract class Factory
{
protected static ?Generator $fake = null;
/**
* Devuelve una instancia de Faker (singleton por fábrica).
*/
protected static function faker(): Generator
{
return static::$fake ??= FakerFactory::create();
}
/**
* Fusiona los atributos por defecto con los indicados.
*
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
protected static function attributes(array $attributes = []): array
{
return array_merge(static::definition(), $attributes);
}
/**
* Atributos por defecto de la fábrica.
*
* @return array<string, mixed>
*/
abstract protected static function definition(): array;
/**
* Crea una instancia del modelo con los atributos dados.
*
* @param array<string, mixed> $attributes
*/
abstract public static function create(array $attributes = []): object;
}

View File

@@ -0,0 +1,234 @@
<?php
namespace Tests\Integration;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use DateTime;
use Libs\Database;
use PDO;
use ReflectionMethod;
use Tests\Models\User;
use Tests\TestCase;
/**
* ModelCrudTest - DuckBrain integration matrix
*
* Family: db, getInstance, getVars, className, table, add, save (new vs
* existing), update, delete, getById, getFirst, all, markAsSaved.
*
* Engine caveats respected here: all()/getFirst() carry no ORDER BY, so
* every assertion targets a specific id or a tally of rows created by this
* class. Rows accumulate across tests on persistent engines; nothing may
* assume an empty table.
*/
#[Group('integration')]
final class ModelCrudTest extends TestCase
{
/** @var list<int> Ids created by this test class (tally for counts). */
private static array $saved = [];
private static function schema(): void
{
self::createTable('users', [
'id' => 'pk',
'username' => 'string',
'email' => 'string',
'bio' => 'text',
'age' => 'number',
'is_active' => 'bool',
'last_login' => 'timestamp',
]);
}
private static function freshUser(): User
{
$n = count(self::$saved) + 1;
$user = new User();
$user->username = 'user' . $n;
$user->email = 'user' . $n . '@duckbrain.dev';
$user->bio = 'bio ' . $n;
$user->age = 20 + $n;
$user->isActive = true;
$user->lastLogin = new DateTime('2026-09-05 10:00:00');
$user->save();
self::$saved[] = $user->id;
return $user;
}
private static function call(mixed $scope, string $method, array $args = []): mixed
{
return (new ReflectionMethod($scope, $method))->invokeArgs(is_object($scope) ? $scope : null, $args);
}
/** @return list<int> */
private static function idsOf(array $instances): array
{
$ids = array_map(static fn (User $u): int => (int) $u->id, $instances);
sort($ids);
return $ids;
}
#[Test]
public function classNameAndTableAreDerivedFromTheClass(): void
{
self::schema();
$this->assertSame('User', User::className());
$this->assertSame('users', self::call(User::class, 'table'));
}
#[Test]
public function dbIsTheSharedSingletonConnection(): void
{
self::schema();
$pdo = self::call(User::class, 'db');
$this->assertInstanceOf(PDO::class, $pdo);
$this->assertSame($pdo, self::call(User::class, 'db'));
}
#[Test]
public function saveInsertsThenUpdatesWithoutDuplicating(): void
{
self::schema();
$user = self::freshUser();
$this->assertGreaterThan(0, $user->id);
$this->assertCount(1, User::where('id', $user->id)->get());
$user->email = 'changed@duckbrain.dev';
$user->age = 40;
$user->save();
$found = User::getById($user->id);
$this->assertSame('changed@duckbrain.dev', $found->email);
$this->assertSame(40, $found->age);
$this->assertCount(1, User::where('id', $user->id)->get(), 'second save() must UPDATE, not INSERT');
}
#[Test]
public function typedPropertiesSurviveTheRoundTrip(): void
{
self::schema();
$user = self::freshUser();
$user->email = null;
$user->save();
$found = User::getById($user->id);
$this->assertIsInt($found->id);
$this->assertNull($found->email, 'nullable typed property must hydrate back as null');
$this->assertIsInt($found->age);
$this->assertInstanceOf(DateTime::class, $found->lastLogin);
$this->assertTrue($found->isActive);
}
#[Test]
public function getByIdReturnsNullForMissingRows(): void
{
self::schema();
$this->assertNull(User::getById(99999));
}
#[Test]
public function getFirstRespectsTheWhereCondition(): void
{
self::schema();
$user = self::freshUser();
$found = User::where('id', $user->id)->getFirst();
$this->assertInstanceOf(User::class, $found);
$this->assertSame($user->id, $found->id);
$this->assertNull(User::where('id', 99999)->getFirst());
}
#[Test]
public function allReturnsEveryInstanceOfThisClass(): void
{
self::schema();
$a = self::freshUser();
$b = self::freshUser();
$all = self::idsOf(User::all());
$this->assertContains($a->id, $all);
$this->assertContains($b->id, $all);
// Defect-tolerant tally: rows deleted by other tests must not count.
$mine = [];
foreach (self::$saved as $id) {
if (User::getById($id) !== null) {
$mine[] = $id;
}
}
sort($mine);
$this->assertSame($mine, $all, 'all() must match exactly the live rows created by this class');
}
#[Test]
public function deleteRemovesTheRow(): void
{
self::schema();
$user = self::freshUser();
$user->delete();
$this->assertNull(User::getById($user->id));
$this->assertNotContains($user->id, self::idsOf(User::all()));
}
#[Test]
public function markAsSavedTurnsTheNextSaveIntoAnUpdate(): void
{
self::schema();
self::freshUser();
$before = count(User::all());
$ghost = new User();
$ghost->id = 900;
$ghost->username = 'ghost';
$ghost->markAsSaved();
$ghost->save();
$this->assertNull(User::getById(900), 'markAsSaved() must prevent INSERT even with a manual id');
$this->assertCount($before, User::all());
}
#[Test]
public function getVarsExposesSnakeCasedCastColumns(): void
{
self::schema();
$user = self::freshUser();
$vars = self::call($user, 'getVars');
$this->assertSame(
['age', 'bio', 'email', 'id', 'is_active', 'last_login', 'username'],
(static function (array $keys): array {
sort($keys);
return $keys;
})(array_keys($vars))
);
$this->assertSame('2026-09-05 10:00:00', $vars['last_login']);
$this->assertIsBool($vars['is_active']);
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace Tests\Integration;
use Exception;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use ReflectionMethod;
use Tests\Models\User;
use Tests\TestCase;
/**
* ModelFailureTest - DuckBrain integration matrix
*
* Family: query-failure state handling (findings F2 / spec model).
*
* All contamination is provoked and observed INSIDE a single test: the
* harness-wide resetQuery guard in Tests\TestCase::setUp() protects the
* next test, never the current one, so these assertions depend solely on
* Model::query()'s own bookkeeping.
*/
#[Group('integration')]
final class ModelFailureTest extends TestCase
{
/** @var list<int> */
private static array $ids = [];
private const BAD_SQL = 'SELECT * FROM table_that_does_not_exist';
public static function setUpBeforeClass(): void
{
self::createTable('users', [
'id' => 'pk',
'username' => 'string',
'email' => 'string',
'bio' => 'text',
'age' => 'number',
'is_active' => 'bool',
'last_login' => 'timestamp',
]);
foreach (['fer', 'fiona', 'frog'] as $name) {
$user = new User();
$user->username = $name;
$user->email = $name . '@duckbrain.dev';
$user->age = 33;
$user->save();
self::$ids[] = (int) $user->id;
}
}
private static function runQuery(string $sql, bool $resetQuery = true): void
{
(new ReflectionMethod(User::class, 'query'))->invokeArgs(null, [$sql, $resetQuery]);
}
private static function currentSql(): string
{
return (new ReflectionMethod(User::class, 'buildQuery'))->invoke(null);
}
#[Test]
public function failingQueryDoesNotContaminateTheNextQuery(): void
{
User::where('id', self::$ids[0]);
try {
self::runQuery(self::BAD_SQL);
$this->fail('a query against a missing table must throw');
} catch (Exception $e) {
$this->assertStringContainsString('Error at query to database', $e->getMessage());
}
// With the default resetQuery: true, the failed run must have left
// the builder as good as new; get() therefore sees all three rows.
$this->assertCount(3, User::get(), 'builder state leaked from the failed query');
}
#[Test]
public function failedKeepStateQueryLeavesTheWhereForRetry(): void
{
User::where('username', 'fiona');
try {
self::runQuery(self::BAD_SQL, false);
$this->fail('a query against a missing table must throw');
} catch (Exception $e) {
$this->assertStringContainsString('Error at query to database', $e->getMessage());
}
// resetQuery: false contracts to keep the where alive for the
// caller's deliberate retry (also guards against a fix that resets
// unconditionally).
$this->assertStringContainsString('WHERE', self::currentSql());
$retry = User::get();
$this->assertCount(1, $retry, 'retry after failure must reuse the kept where');
$this->assertSame('fiona', $retry[0]->username);
}
}

View File

@@ -0,0 +1,245 @@
<?php
namespace Tests\Integration;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Libs\Model;
use Tests\Models\User;
use Tests\TestCase;
/**
* ModelFilterTest - DuckBrain integration matrix
*
* Family: select, from, where, and, or, whereIn, whereNotIn, whereNull,
* whereNotNull, whereExists, whereNotExists, limit, orderBy, count, plus
* resetQuery/buildQuery observed through behavior (state must not leak
* between executed queries).
*/
#[Group('integration')]
final class ModelFilterTest extends TestCase
{
/** @var list<int> ids of alice, bob, carol, dave */
private static array $ids = [];
public static function setUpBeforeClass(): void
{
self::createTable('users', [
'id' => 'pk',
'username' => 'string',
'email' => 'string',
'bio' => 'text',
'age' => 'number',
'is_active' => 'bool',
'last_login' => 'timestamp',
]);
self::createTable('posts', [
'id' => 'pk',
'user_id' => 'number',
'title' => 'string',
'content' => 'text',
'rating' => 'number',
]);
$rows = [
['alice', 'alice@duckbrain.dev', 'first', 20, true],
['bob', 'bob@duckbrain.dev', 'second', 30, true],
['carol', null, 'third', 40, false],
['dave', 'dave@duckbrain.dev', null, 50, true],
];
foreach ($rows as [$username, $email, $bio, $age, $active]) {
$user = new User();
$user->username = $username;
$user->email = $email;
$user->bio = $bio;
$user->age = $age;
$user->isActive = $active;
$user->save();
self::$ids[] = (int) $user->id;
}
// A post owned by alice only, for the EXISTS tests.
$post = new \Tests\Models\Post();
$post->userId = self::$ids[0];
$post->title = 'hello world';
$post->content = 'body';
$post->rating = 4.5;
$post->save();
}
/** @return list<int> */
private static function sortedIds(array $instances): array
{
$ids = array_map(static fn (Model $m): int => (int) $m->id, $instances);
sort($ids);
return $ids;
}
#[Test]
public function whereEqualityAndOperatorForms(): void
{
$this->assertSame(
[self::$ids[1]],
self::sortedIds(User::where('username', 'bob')->get())
);
$this->assertSame(
[self::$ids[2], self::$ids[3]],
self::sortedIds(User::where('age', '>', '35')->get())
);
}
#[Test]
public function andNestsConditions(): void
{
$this->assertSame(
[self::$ids[3]],
self::sortedIds(User::where('age', '>', '25')->and('username', 'dave')->get())
);
}
#[Test]
public function orCombinesAlternativeConditions(): void
{
$this->assertSame(
[self::$ids[0], self::$ids[3]],
self::sortedIds(User::where('username', 'alice')->or('username', 'dave')->get())
);
}
#[Test]
public function whereInAndWhereNotIn(): void
{
$in = self::sortedIds(User::whereIn('username', ['alice', 'carol'])->get());
$this->assertSame([self::$ids[0], self::$ids[2]], $in);
$notIn = self::sortedIds(User::whereNotIn('username', ['alice', 'carol'])->get());
$this->assertSame([self::$ids[1], self::$ids[3]], $notIn);
}
#[Test]
public function whereNullAndWhereNotNull(): void
{
$this->assertSame(
[self::$ids[3]],
self::sortedIds(User::whereNull('bio')->get())
);
$this->assertSame(
[self::$ids[2]],
self::sortedIds(User::whereNull('email')->get())
);
$this->assertCount(3, User::whereNotNull('email')->get());
}
#[Test]
public function whereExistsAndWhereNotExists(): void
{
$correlated = 'SELECT 1 FROM posts WHERE posts.user_id = users.id';
$this->assertSame(
[self::$ids[0]],
self::sortedIds(User::whereExists($correlated)->get())
);
$this->assertSame(
[self::$ids[1], self::$ids[2], self::$ids[3]],
self::sortedIds(User::whereNotExists($correlated)->get())
);
}
#[Test]
public function selectAndFromRestrictTheProjection(): void
{
$rows = User::select('username')->from('users')->where('username', 'bob')->get();
$this->assertCount(1, $rows);
$this->assertSame('bob', $rows[0]->username);
}
#[Test]
public function limitSingleAndOffsetForms(): void
{
$firstTwo = User::orderBy('id', 'ASC')->limit(2)->get();
$this->assertSame(
[self::$ids[0], self::$ids[1]],
array_map(static fn (Model $m): int => (int) $m->id, $firstTwo)
);
// limit(offset, quantity) -> skip the first two.
$lastTwo = User::orderBy('id', 'ASC')->limit(2, 2)->get();
$this->assertSame(
[self::$ids[2], self::$ids[3]],
array_map(static fn (Model $m): int => (int) $m->id, $lastTwo)
);
}
#[Test]
public function orderByColumnAscendingAndDescending(): void
{
$asc = array_map(static fn (Model $m): string => (string) $m->age, User::orderBy('age', 'ASC')->get());
$this->assertSame(['20', '30', '40', '50'], array_map('strval', array_map('intval', $asc)));
$descFirst = User::orderBy('age', 'DESC')->getFirst();
$this->assertSame('dave', $descFirst->username);
}
#[Test]
public function countWithAndWithoutConditions(): void
{
$this->assertSame(4, User::count());
$this->assertSame(3, User::where('age', '>', '25')->count());
}
#[Test]
public function countWithUseLimit(): void
{
$this->assertSame(2, User::orderBy('id', 'ASC')->limit(2)->count(true, true));
}
#[Test]
public function countWithoutResetKeepsTheOriginalQuery(): void
{
User::where('age', '>', '25');
$count = User::count(false);
$this->assertSame(3, $count);
$this->assertSame(
[self::$ids[1], self::$ids[2], self::$ids[3]],
self::sortedIds(User::get()),
'count(false) must leave the where active; get() consumes it'
);
}
#[Test]
public function queryStateResetsAfterExecution(): void
{
$one = User::where('id', self::$ids[0])->getFirst();
$this->assertSame('alice', $one->username);
$this->assertCount(4, User::all(), 'where must not leak into the next query');
$this->assertCount(4, User::get());
}
#[Test]
public function orderByRandReturnsEveryRow(): void
{
// The readme matrix promises orderBy ok on the three engines.
// Model emits literal RAND(); sqlite and PostgreSQL know RANDOM().
// This test documents the promise; failures are findings, not noise.
$this->assertCount(4, User::orderBy('RAND')->get());
}
}

View File

@@ -0,0 +1,145 @@
<?php
namespace Tests\Integration;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Tests\Models\Post;
use Tests\Models\User;
use Tests\TestCase;
/**
* ModelJoinTest - DuckBrain integration matrix
*
* Family: innerJoin, leftJoin, rightJoin, crossJoin, groupBy.
*
* Data has orphans on both sides (carol/dave have no posts; no post lacks an
* owner) so join cardinality actually discriminates. Projections are always
* qualified and limited to driver-model columns: a bare SELECT * on
* rightJoin differs between engines BY DESIGN (the sqlite branch rewrites
* into a swapped LEFT JOIN, see readme "fixed"); asserting on ambiguous
* duplicate columns would test that rewrite detail, not the join promise.
*/
#[Group('integration')]
final class ModelJoinTest extends TestCase
{
/** @var array<string,int> username => id */
private static array $ids = [];
public static function setUpBeforeClass(): void
{
self::createTable('users', [
'id' => 'pk',
'username' => 'string',
'email' => 'string',
'bio' => 'text',
'age' => 'number',
'is_active' => 'bool',
'last_login' => 'timestamp',
]);
self::createTable('posts', [
'id' => 'pk',
'user_id' => 'number',
'title' => 'string',
'content' => 'text',
'rating' => 'number',
]);
foreach ([['alice', true], ['bob', true], ['carol', false], ['dave', true]] as $row) {
$user = new User();
$user->username = $row[0];
$user->email = $row[0] . '@duckbrain.dev';
$user->age = 30;
$user->isActive = $row[1];
$user->save();
self::$ids[$row[0]] = (int) $user->id;
}
foreach ([['p1', 'alice'], ['p2', 'alice'], ['p3', 'bob']] as [$title, $owner]) {
$post = new Post();
$post->title = $title;
$post->userId = self::$ids[$owner];
$post->save();
}
}
/** @return list<string> sorted usernames of the result rows */
private static function names(array $rows): array
{
$names = array_map(static fn (User $u): string => (string) $u->username, $rows);
sort($names);
return $names;
}
#[Test]
public function leftJoinKeepsUsersWithoutPosts(): void
{
$rows = User::select('users.id', 'users.username')
->leftJoin('posts', 'users.id', '=', 'posts.user_id')
->get();
$this->assertSame(
['alice', 'alice', 'bob', 'carol', 'dave'],
self::names($rows),
'LEFT JOIN must keep orphan users exactly once each'
);
}
#[Test]
public function innerJoinDropsBothOrphans(): void
{
$rows = User::select('users.id', 'users.username')
->innerJoin('posts', 'users.id', '=', 'posts.user_id')
->get();
$this->assertSame(['alice', 'alice', 'bob'], self::names($rows));
}
#[Test]
public function rightJoinKeepsEveryRowOfTheJoinedTable(): void
{
// sqlite takes the swapped-LEFT-JOIN rewrite; mysql/pgsql take the
// native RIGHT JOIN branch. Both must produce the same observable set.
$rows = User::select('users.id', 'users.username')
->rightJoin('posts', 'users.id', '=', 'posts.user_id')
->get();
$names = self::names($rows);
$this->assertCount(3, $rows, 'posts are the preserved side: 3 rows');
$this->assertSame(['alice', 'alice', 'bob'], $names);
$this->assertNotContains('carol', $names, 'users without posts vanish from a RIGHT JOIN over posts');
}
#[Test]
public function crossJoinProducesTheCartesianProduct(): void
{
$rows = User::select('users.id', 'posts.id')
->crossJoin('posts')
->get();
$this->assertCount(12, $rows, '4 users x 3 posts');
// Qualified both-id columns: the last one wins on every engine
// (users.* comes first), so the surviving id set is 1..3.
$surviving = array_unique(array_map(static fn (User $u): int => (int) $u->id, $rows));
sort($surviving);
$this->assertSame([1, 2, 3], $surviving);
}
#[Test]
public function groupByCollapsesRowsPerGroup(): void
{
$statuses = User::select('users.is_active')->groupBy('users.is_active')->get();
$this->assertCount(2, $statuses, 'active and inactive groups');
$owners = User::select('users.id', 'users.username')
->innerJoin('posts', 'users.id', '=', 'posts.user_id')
->groupBy('users.id')
->get();
$names = self::names($owners);
sort($names);
$this->assertSame(['alice', 'bob'], $names, 'one row per user that owns posts');
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace Tests\Integration;
use DateTime;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Tests\Models\User;
use Tests\TestCase;
/**
* ModelSearchTest - DuckBrain integration matrix
*
* Family: search (mysql CONCAT branch vs sqlite/pgsql CAST||LIKE branch),
* plus filters that bind every supported column type and must agree on
* matches across engines. Search terms are chosen case-insensitively stable
* (MySQL LIKE is case-insensitive by collation, PostgreSQL's is not).
*/
#[Group('integration')]
final class ModelSearchTest extends TestCase
{
/** @var array<string,int> */
private static array $ids = [];
public static function setUpBeforeClass(): void
{
self::createTable('users', [
'id' => 'pk',
'username' => 'string',
'email' => 'string',
'bio' => 'text',
'age' => 'number',
'is_active' => 'bool',
'last_login' => 'timestamp',
]);
$seed = [
['alice', 'alice@duckbrain.dev', 'alpha keeper', 20, true],
['bob', 'bob@duckbrain.dev', 'beta reader', 30, true],
['carol', 'carol@duckbrain.dev', 'gamma dreamer', 40, false],
['dave', 'dave@duckbrain.dev', 'delta builder', 50, true],
];
foreach ($seed as [$username, $email, $bio, $age, $active]) {
$user = new User();
$user->username = $username;
$user->email = $email;
$user->bio = $bio;
$user->age = $age;
$user->isActive = $active;
$user->lastLogin = new DateTime('2026-09-05 10:00:00');
$user->save();
self::$ids[$username] = (int) $user->id;
}
}
/** @return list<int> */
private static function idsOf(array $rows): array
{
$ids = array_map(static fn (User $u): int => (int) $u->id, $rows);
sort($ids);
return $ids;
}
#[Test]
public function searchMatchesAcrossAllDefaultColumns(): void
{
// 'ali' occurs only in alice's username/bio space.
$this->assertSame(
[self::$ids['alice']],
self::idsOf(User::search('ali')->get())
);
}
#[Test]
public function searchFindsNumericValueThroughTextCasting(): void
{
// The number column must be matched by its textual form: sqlite
// casts REAL to '30.0' while mysql/pgsql yield '30'; both contain 30.
$this->assertSame(
[self::$ids['bob']],
self::idsOf(User::search('30')->get())
);
}
#[Test]
public function searchHonorsExplicitColumnList(): void
{
$this->assertSame(
[self::$ids['alice']],
self::idsOf(User::search('lpha', ['bio'])->get())
);
$this->assertSame(
[self::$ids['carol']],
self::idsOf(User::search('arol', ['username', 'email'])->get())
);
}
#[Test]
public function searchCombinesWithWhere(): void
{
$this->assertSame(
[self::$ids['alice']],
self::idsOf(User::where('is_active', '1')->search('li')->get())
);
}
#[Test]
public function everyColumnTypeFiltersConsistently(): void
{
$this->assertSame([self::$ids['alice']], self::idsOf(User::where('id', self::$ids['alice'])->get()));
$this->assertSame([self::$ids['bob']], self::idsOf(User::where('email', 'bob@duckbrain.dev')->get()));
$this->assertSame([self::$ids['carol']], self::idsOf(User::where('is_active', '0')->get()));
$this->assertCount(4, User::where('last_login', '>', '2026-09-05 09:00:00')->get());
$this->assertSame([self::$ids['dave']], self::idsOf(User::where('age', '>=', '50')->get()));
}
#[Test]
public function booleanFalseSurvivesTheRoundTrip(): void
{
// Promise of the matrix: typed properties hydrate faithfully on the
// three engines. PostgreSQL reports boolean false as 'f' and the
// core casts nothing -> this is the R2 probe.
$carol = User::getById(self::$ids['carol']);
$this->assertNotNull($carol);
$this->assertFalse($carol->isActive, 'inactive user must hydrate as bool false');
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace Tests\Integration;
use Exception;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use ReflectionMethod;
use Tests\Models\User;
use Tests\TestCase;
/**
* ModelTransactionTest - DuckBrain integration matrix
*
* Family: beginTransaction, commit, rollBack, plus the implicit rollBack
* that Model.php performs when a query fails mid-transaction (and the
* Exception it rethrows wrapping the PDO error).
*/
#[Group('integration')]
final class ModelTransactionTest extends TestCase
{
public static function setUpBeforeClass(): void
{
self::createTable('users', [
'id' => 'pk',
'username' => 'string',
'email' => 'string',
'bio' => 'text',
'age' => 'number',
'is_active' => 'bool',
'last_login' => 'timestamp',
]);
}
private static function seed(string $username): User
{
$user = new User();
$user->username = $username;
$user->email = $username . '@duckbrain.dev';
$user->age = 25;
$user->save();
return $user;
}
#[Test]
public function commitMakesPendingWritesVisible(): void
{
User::beginTransaction();
$user = self::seed('committed_alice');
$this->assertTrue(User::commit());
$this->assertNotNull(User::getById($user->id));
}
#[Test]
public function rollBackDiscardsPendingWrites(): void
{
User::beginTransaction();
$user = self::seed('rolled_bob');
$this->assertTrue(User::rollBack());
$this->assertNull(User::getById($user->id));
}
#[Test]
public function aFailingQueryRollsBackTheOpenTransaction(): void
{
User::beginTransaction();
$user = self::seed('wrecked_carol');
// Dirty the builder BEFORE failing: only a leftover-free result after
// the exception proves query() itself cleaned up (finding F2).
User::where('age', '>', '30');
try {
(new ReflectionMethod(User::class, 'query'))
->invokeArgs(null, ['SELECT * FROM table_that_does_not_exist']);
$this->fail('a failing query inside a transaction must throw');
} catch (Exception $e) {
$this->assertStringContainsString('Error at query to database', $e->getMessage());
}
$this->assertFalse(self::db()->inTransaction(), 'the exception path must close the transaction');
$sqlAfterFailure = (new ReflectionMethod(User::class, 'buildQuery'))->invoke(null);
$this->assertSame(
'SELECT * FROM users',
$sqlAfterFailure,
'a failed query must leave the builder at its default state'
);
$this->assertNull(User::where('username', 'wrecked_carol')->getFirst(), 'pending insert must be undone');
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace Tests\Integration;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use DateTime;
use InvalidArgumentException;
use Tests\Models\User;
use Tests\TestCase;
/**
* SchemaSmokeTest - DuckBrain test harness
*
* Integration smoke test: the six logical column types must translate into
* valid DDL for the current engine and round-trip a row. Runs on all three
* legs (sqlite, mysql, pgsql) unchanged.
*/
#[Group('integration')]
final class SchemaSmokeTest extends TestCase
{
#[Test]
public function allLogicalTypesCreateAndRoundTrip(): void
{
self::createTable('smoke_types', [
'id' => 'pk',
'label' => 'string',
'body' => 'text',
'amount' => 'number',
'active' => 'bool',
'created_at' => 'timestamp',
]);
$insert = self::db()->prepare(
'INSERT INTO smoke_types (label, body, amount, active, created_at) VALUES (?, ?, ?, ?, ?)'
);
$insert->execute(['widget', 'a longer free-form body', 12.5, true, '2026-09-05 12:34:56']);
$row = self::db()->query('SELECT * FROM smoke_types')->fetch();
$this->assertSame(1, (int) $row['id']);
$this->assertSame('widget', $row['label']);
$this->assertSame('a longer free-form body', $row['body']);
$this->assertEquals(12.5, (float) $row['amount']);
$this->assertTrue(filter_var($row['active'], FILTER_VALIDATE_BOOLEAN));
$this->assertStringStartsWith('2026-09-05 12:34:56', (string) $row['created_at']);
}
#[Test]
public function saveAssignsPrimaryKeyAndGetByIdRoundTrips(): void
{
self::createTable('users', [
'id' => 'pk',
'username' => 'string',
'email' => 'string',
'bio' => 'text',
'age' => 'number',
'is_active' => 'bool',
'last_login' => 'timestamp',
]);
$user = new User();
$user->username = 'keyjay';
$user->email = 'kj@duckbrain.dev';
$user->bio = 'framework keeper';
$user->age = 29;
$user->isActive = true;
$user->lastLogin = new DateTime('2026-09-05 10:00:00');
$user->save();
$this->assertSame(1, $user->id, 'save() should assign the generated primary key');
$found = User::getById(1);
$this->assertNotNull($found);
$this->assertSame('keyjay', $found->username);
$this->assertSame(29, $found->age);
$this->assertTrue($found->isActive);
$this->assertInstanceOf(DateTime::class, $found->lastLogin);
$this->assertSame('2026-09-05 10:00:00', $found->lastLogin->format('Y-m-d H:i:s'));
}
#[Test]
public function unknownLogicalTypeFails(): void
{
$this->expectException(InvalidArgumentException::class);
self::createTable('smoke_bad', ['x' => 'money']);
}
}

20
tests/Models/Post.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
namespace Tests\Models;
use Libs\Model;
/**
* Post - DuckBrain test fixture
*
* Second table of the integration matrix, joined to Users through user_id.
* Column names avoid SQL reserved words on every engine.
*/
final class Post extends Model
{
public ?int $id;
public ?int $userId;
public string $title;
public ?string $content;
public ?float $rating;
}

26
tests/Models/User.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
namespace Tests\Models;
use DateTime;
use Libs\Model;
/**
* User - DuckBrain test fixture
*
* Public model used by the integration matrix. $id is declared WITHOUT a
* default so it stays uninitialized until the database assigns it:
* Model::getVars() skips uninitialized typed properties, which lets
* AUTOINCREMENT / AUTO_INCREMENT / SERIAL generate the primary key and
* Model::add() assign it back to the instance.
*/
final class User extends Model
{
public ?int $id;
public string $username;
public ?string $email;
public ?string $bio;
public ?int $age;
public bool $isActive = false;
public ?DateTime $lastLogin;
}

136
tests/TestCase.php Normal file
View File

@@ -0,0 +1,136 @@
<?php
namespace Tests;
use Libs\Database;
use Libs\Model;
use PDO;
use PHPUnit\Framework\TestCase as FrameworkTestCase;
/**
* TestCase - DuckBrain test harness
*
* Base class for the framework's own tests. Deliberately lightweight: this
* repo has no migrations/ nor duckbrain-commands, so each test defines its
* schema with raw DDL through createTable(), and tables registered there are
* dropped automatically after the class finishes.
*/
abstract class TestCase extends FrameworkTestCase
{
/**
* @var list<string> Tables created through createTable() for this class.
*/
private static array $tables = [];
/**
* Clears Model's static query-builder state before every test.
*
* A query that dies mid-flight (unsupported function, constraint
* violation, ...) never reaches Model's internal resetQuery(), so
* leftovers would leak into the next test otherwise. This keeps the
* suite order-independent regardless of PHPUnit's executionOrder.
*/
protected function setUp(): void
{
parent::setUp();
(new \ReflectionMethod(Model::class, 'resetQuery'))->invoke(null);
}
/**
* Returns the PDO connection to the in-memory test database.
*
* @return PDO
*/
protected static function db(): PDO
{
return Database::getInstance(DB_TYPE, DB_HOST, DB_NAME, DB_USER, DB_PASS);
}
/**
* DDL fragments per engine for each logical column type.
*
* @var array<string,array<string,string>>
*/
private const TYPE_DDL = [
'sqlite' => [
'pk' => 'INTEGER PRIMARY KEY AUTOINCREMENT',
'string' => 'VARCHAR(255)',
'text' => 'TEXT',
'number' => 'REAL',
'bool' => 'INTEGER',
'timestamp' => 'TEXT',
],
'mysql' => [
'pk' => 'INT AUTO_INCREMENT PRIMARY KEY',
'string' => 'VARCHAR(255)',
'text' => 'TEXT',
'number' => 'DOUBLE',
'bool' => 'TINYINT(1)',
'timestamp' => 'DATETIME',
],
'pgsql' => [
'pk' => 'SERIAL PRIMARY KEY',
'string' => 'VARCHAR(255)',
'text' => 'TEXT',
'number' => 'DOUBLE PRECISION',
'bool' => 'BOOLEAN',
'timestamp' => 'TIMESTAMP',
],
];
/**
* Creates a table in the test database from logical column types and
* registers it for cleanup. The DDL is translated per engine, so the
* same declaration works on sqlite, MySQL/MariaDB and PostgreSQL.
*
* @param string $name
* Table name (avoid SQL reserved words).
*
* @param array<string,string> $columns
* Map of column name to logical type:
* pk|string|text|number|bool|timestamp.
*/
protected static function createTable(string $name, array $columns): void
{
$types = self::TYPE_DDL[DB_TYPE] ?? throw new \InvalidArgumentException(
'No type translation for DB_TYPE ' . DB_TYPE
);
$ddl = [];
foreach ($columns as $column => $type) {
$fragment = $types[$type] ?? throw new \InvalidArgumentException(
"Unknown logical type '{$type}' for column '{$column}'; "
. 'expected: ' . implode(', ', array_keys($types))
);
$ddl[] = $column . ' ' . $fragment;
}
static::db()->exec("CREATE TABLE IF NOT EXISTS {$name} (" . implode(', ', $ddl) . ')');
static::$tables[] = $name;
}
/**
* Never let an aborted test leak an open transaction into the next one.
*/
protected function tearDown(): void
{
if (static::db()->inTransaction()) {
static::db()->rollBack();
}
parent::tearDown();
}
/**
* Drops every table registered through createTable() during this class.
*/
public static function tearDownAfterClass(): void
{
foreach (array_unique(static::$tables) as $table) {
static::db()->exec("DROP TABLE IF EXISTS {$table}");
}
static::$tables = [];
}
}

0
tests/Unit/.gitkeep Normal file
View File

View File

@@ -0,0 +1,57 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\Attributes\Test;
use Libs\Neuron;
use PDO;
use Tests\TestCase;
/**
* BootstrapTest - DuckBrain test harness
*
* Prueba de humo de la infraestructura: autoload de composer + framework,
* constantes de la DB de prueba y conexión PDO realmente utilizable. Si
* algo del entorno está mal, esta prueba falla primero con el diagnóstico.
*/
final class BootstrapTest extends TestCase
{
#[Test]
public function frameworkClassesAreAutoloaded(): void
{
$this->assertTrue(
class_exists(Neuron::class),
'MISSING AUTOLOAD: autoload.php did not resolve Libs\Neuron; check ROOT_CORE and run from the project root'
);
$neuron = new Neuron(['key' => 'value']);
$this->assertSame('value', $neuron->key);
$this->assertNull($neuron->doesNotExist);
}
#[Test]
public function databaseConstantsPointToInMemorySqlite(): void
{
$this->assertSame(
'sqlite',
DB_TYPE,
'MISSING OVERRIDE: DB_TYPE comes from config.php; the bootstrap must define the DB_* constants BEFORE requiring autoload.php'
);
$this->assertSame(':memory:', DB_NAME, 'DB_NAME should be :memory: so tests never touch disks or servers');
}
#[Test]
public function testDatabaseIsUsable(): void
{
$db = self::db();
$this->assertInstanceOf(PDO::class, $db, 'Could not obtain the test PDO connection');
$this->assertSame($db, self::db(), 'Database::getInstance should return the same singleton');
$db->exec('CREATE TABLE smoke (id INTEGER PRIMARY KEY, v TEXT)');
$db->prepare('INSERT INTO smoke (v) VALUES (?)')->execute(['quack']);
$this->assertSame('quack', $db->query('SELECT v FROM smoke')->fetchColumn());
$db->exec('DROP TABLE smoke');
}
}

69
tests/Unit/NeuronTest.php Normal file
View File

@@ -0,0 +1,69 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\Attributes\Test;
use Libs\Neuron;
use Tests\TestCase;
/**
* NeuronTest - DuckBrain test harness
*
* Regression net para el contenedor de valores del core: construcción
* desde array/objeto, propiedades dinámicas y null en inexistentes.
*/
final class NeuronTest extends TestCase
{
#[Test]
public function constructFromAssociativeArray(): void
{
$n = new Neuron(['username' => 'kj', 'level' => 3]);
$this->assertSame('kj', $n->username);
$this->assertSame(3, $n->level);
}
#[Test]
public function constructFromObjectCopiesPublicProperties(): void
{
$source = new \stdClass();
$source->id = 7;
$source->email = 'kj@example.com';
$n = new Neuron($source);
$this->assertSame(7, $n->id);
$this->assertSame('kj@example.com', $n->email);
}
#[Test]
public function undefinedPropertyIsNullWithoutNotice(): void
{
$n = new Neuron();
$this->assertNull($n->thisDoesNotExist);
}
#[Test]
public function dynamicPropertiesCanBeAssignedAndRead(): void
{
$n = new Neuron();
$n->fresh = ['a', 'b'];
$this->assertSame(['a', 'b'], $n->fresh);
}
#[Test]
public function nestedValuesArePreservedVerbatim(): void
{
$payload = ['meta' => ['tags' => ['x', 'y'], 'n' => null]];
$n = new Neuron($payload);
$this->assertSame($payload['meta'], $n->meta);
$this->assertNull($n->meta['n']);
}
}

138
tests/Unit/RequestTest.php Normal file
View File

@@ -0,0 +1,138 @@
<?php
namespace Tests\Unit;
use Exception;
use Libs\Request;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* RequestTest - DuckBrain test harness
*
* Cubre el modelo de fallo por excepcion del Request: la validacion lanza
* con code 422 y el mensaje unico de Validator::message() (con overrides de
* messages()/attributes()), en lugar de responder HTTP y hacer exit() como
* hacia antes. Que estas pruebas puedan ejecutar expectException ya demuestra
* que el constructor no termina el proceso.
*/
final class RequestTest extends TestCase
{
private array $serverBackup = [];
private array $getBackup = [];
private array $postBackup = [];
protected function setUp(): void
{
$this->serverBackup = $_SERVER;
$this->getBackup = $_GET;
$this->postBackup = $_POST;
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/test';
$_SERVER['DOCUMENT_ROOT'] = '/nonexistent-docroot';
unset($_SERVER['CONTENT_TYPE'], $_SERVER['HTTP_ACCEPT']);
$_GET = [];
$_POST = [];
}
protected function tearDown(): void
{
$_SERVER = $this->serverBackup;
$_GET = $this->getBackup;
$_POST = $this->postBackup;
}
#[Test]
public function failedValidationThrowsWithHttp422Code(): void
{
$_SERVER['REQUEST_METHOD'] = 'POST';
$_POST = ['age' => '5'];
$this->expectException(Exception::class);
$this->expectExceptionMessage('The age must be at least 18.');
$this->expectExceptionCode(422);
new class extends Request {
public function rules(): array
{
return ['age' => 'required|min:18'];
}
};
}
#[Test]
public function messagesOverrideWinsForFailedRule(): void
{
$_SERVER['REQUEST_METHOD'] = 'POST';
$_POST = ['age' => '5'];
$this->expectException(Exception::class);
$this->expectExceptionMessage('Way too short');
new class extends Request {
public function rules(): array
{
return ['age' => 'required|min:18'];
}
public function messages(): array
{
return ['age.min' => 'Way too short'];
}
};
}
#[Test]
public function attributesRenameTheFieldInDefaultMessage(): void
{
$_SERVER['REQUEST_METHOD'] = 'POST';
$_POST = ['age' => '5'];
$this->expectException(Exception::class);
$this->expectExceptionMessage('The applicant age must be at least 18.');
new class extends Request {
public function rules(): array
{
return ['age' => 'required|min:18'];
}
public function attributes(): array
{
return ['age' => 'applicant age'];
}
};
}
#[Test]
public function getRulesFailureThrowsToo(): void
{
$this->expectException(Exception::class);
$this->expectExceptionMessage('The q field is required.');
new class extends Request {
public function getRules(): array
{
return ['q' => 'required'];
}
};
}
#[Test]
public function validDataDoesNotThrow(): void
{
$_SERVER['REQUEST_METHOD'] = 'POST';
$_POST = ['age' => '20'];
$request = new class extends Request {
public function rules(): array
{
return ['age' => 'required|min:18'];
}
};
$this->assertSame('20', $request->post->age);
$this->assertSame('/test', $request->path);
}
}

317
tests/Unit/RouterTest.php Normal file
View File

@@ -0,0 +1,317 @@
<?php
namespace Tests\Unit;
use Exception;
use Libs\Neuron;
use Libs\Request;
use Libs\Router;
use LogicException;
use PDOException;
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
use PHPUnit\Framework\Attributes\Test;
use ReflectionClass;
use ReflectionProperty;
use Tests\TestCase;
use TypeError;
/**
* RouterTest - DuckBrain test harness
*
* Cubre la respuesta de error que produce Router::defaultException(): el
* status derivado del code de la excepcion con guard de rango 400-599 y la
* negociacion por Accept entre representacion JSON y texto plano. Tambien la
* frontera de Router::apply(): que toda excepcion de la cadena (middlewares,
* callback final, notFound) llegue al $exceptionCallback deteniendo la
* ejecucion sin exit() y dejando intacto el flujo normal. Tambien que los
* valores por defecto declarados en las propiedades $notFoundCallback y
* $exceptionCallback (callables string) se resuelvan de punta a punta a
* traves de Synapsis::resolve() dentro de apply().
*/
final class RouterTest extends TestCase
{
private array $serverBackup = [];
private array $getBackup = [];
private array $postBackup = [];
protected function setUp(): void
{
$this->serverBackup = $_SERVER;
$this->getBackup = $_GET;
$this->postBackup = $_POST;
$_SERVER['REQUEST_METHOD'] = 'GET';
unset($_SERVER['HTTP_ACCEPT']);
@http_response_code(200); // Ver tearDown: inofensivo mientras no haya un header('HTTP/...') activo.
$this->resetRouterState();
}
protected function tearDown(): void
{
$_SERVER = $this->serverBackup;
$_GET = $this->getBackup;
$_POST = $this->postBackup;
// El @ cubre el warning de PHP 8.5 ("...has no effect") que dispara
// cualquier http_response_code() posterior a un header('HTTP/...'),
// inevitable en el proceso aislado del test de notFound (el valor
// se aplica igualmente, como confirma el getter).
@http_response_code(200);
$this->resetRouterState();
}
private function resetRouterState(): void
{
foreach (['get', 'post', 'put', 'patch', 'delete'] as $method) {
(new ReflectionProperty(Router::class, $method))->setValue(null, []);
}
Router::$params = new Neuron();
Router::$notFoundCallback = 'Libs\Router::defaultNotFound';
Router::$exceptionCallback = 'Libs\Router::defaultException';
}
private function render(\Throwable $exception): string
{
ob_start();
Router::defaultException($exception);
return ob_get_clean();
}
#[Test]
public function intCodeInHttpStatusRangeBecomesResponseStatus(): void
{
$output = $this->render(new Exception('The edad must be at least 18.', 422));
$this->assertSame(422, http_response_code());
$this->assertStringContainsString('The edad must be at least 18.', $output);
}
#[Test]
public function stringSqlStateCodeFallsBackToServerError(): void
{
$this->render(new PDOException('SQLSTATE[23000]: Integrity constraint violation', '23000'));
$this->assertSame(500, http_response_code(), 'A string SQLSTATE code must never reach http_response_code');
}
#[Test]
public function intCodeOutOfRangeFallsBackToServerError(): void
{
$this->render(new Exception('arbitrary code', 7));
$this->assertSame(500, http_response_code());
}
#[Test]
public function throwableWithoutMeaningfulCodeAnswersServerError(): void
{
$this->render(new TypeError('must be of the type int'));
$this->assertSame(500, http_response_code());
}
#[Test]
public function plainTextIsDefaultRepresentation(): void
{
$output = $this->render(new Exception('boom', 418));
$this->assertStringStartsWith("boom\n", $output);
$this->assertStringContainsString('#0', $output, 'Plain text must include the full trace');
}
#[Test]
public function jsonRepresentationWhenClientAcceptsJson(): void
{
$_SERVER['HTTP_ACCEPT'] = 'application/json';
$output = $this->render(new Exception('The e must be a valid email address.', 422));
$payload = json_decode($output, true);
$this->assertIsArray($payload, 'Accept: application/json must produce a JSON body');
$this->assertSame(422, http_response_code());
$this->assertSame('The e must be a valid email address.', $payload['error']);
$this->assertSame('Exception', $payload['exception']);
$this->assertArrayHasKey('file', $payload);
$this->assertArrayHasKey('line', $payload);
$this->assertIsArray($payload['trace']);
$this->assertIsString($payload['trace'][0], 'The trace must be serialized as readable lines, not raw frames');
}
#[Test]
public function middlewareExceptionStopsTheRemainingChain(): void
{
$log = [];
$caught = [];
Router::$exceptionCallback = function (\Throwable $exception) use (&$caught): void {
$caught[] = $exception;
};
Router::get('/chain', function () use (&$log): void {
$log[] = 'final';
});
Router::middleware(function () use (&$log): void {
$log[] = 'mw-late-registered';
});
Router::middleware(function () use (&$log): void {
throw new Exception('chain break', 422);
});
Router::middleware(function () use (&$log): void {
$log[] = 'mw-first-registered';
});
Router::apply('/chain');
// Callbacks run in reverse registration order: the last registered middleware
// ran first, the thrower stopped everything after it, and apply() returned
// normally (this line being reached proves there was no exit()).
$this->assertSame(['mw-first-registered'], $log);
$this->assertCount(1, $caught, 'The route chain must deliver exactly one exception to the callback');
$this->assertSame('chain break', $caught[0]->getMessage());
$this->assertSame(422, $caught[0]->getCode());
}
#[Test]
public function finalCallbackExceptionIsDeliveredToTheExceptionCallback(): void
{
$caught = [];
Router::$exceptionCallback = function (\Throwable $exception) use (&$caught): void {
$caught[] = get_class($exception) . ': ' . $exception->getMessage();
};
Router::get('/throwing', function (): void {
throw new LogicException('controller boom');
});
Router::apply('/throwing');
$this->assertSame(['LogicException: controller boom'], $caught);
}
#[Test]
public function notFoundCallbackExceptionReachesTheExceptionCallback(): void
{
$caught = [];
Router::$exceptionCallback = function (\Throwable $exception) use (&$caught): void {
$caught[] = $exception;
};
Router::$notFoundCallback = function (): void {
throw new Exception('not found exploded', 418);
};
Router::apply('/no-such-route');
$this->assertCount(1, $caught);
$this->assertSame('not found exploded', $caught[0]->getMessage());
$this->assertSame(418, $caught[0]->getCode());
}
#[Test]
public function normalFlowStillPrintsReturnedData(): void
{
Router::$exceptionCallback = function (): void {
$this->fail('The exception callback must not run on a successful request');
};
Router::get('/ok', function (): array {
return ['status' => 'ok'];
});
ob_start();
Router::apply('/ok');
$output = ob_get_clean();
$this->assertSame('{"status":"ok"}', $output);
$this->assertSame(200, http_response_code());
}
#[Test]
public function customOnInvalidExceptionReachesTheExceptionCallbackThroughTheBoundary(): void
{
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/signup';
$_SERVER['DOCUMENT_ROOT'] = '/nonexistent-docroot';
$_POST = ['age' => '5'];
$caught = [];
Router::$exceptionCallback = function (\Throwable $exception) use (&$caught): void {
$caught[] = $exception;
};
$finalRan = false;
Router::post('/signup', function (StrictSignup $request) use (&$finalRan): void {
$finalRan = true;
});
Router::apply('/signup');
// The Request was built by the container for the final callback and its
// custom onInvalid() threw during dependency resolution: the custom type
// must arrive intact to the callback, and the final callback must not run.
$this->assertCount(1, $caught);
$this->assertInstanceOf(RequestRejected::class, $caught[0]);
$this->assertSame('The age must be at least 18.', $caught[0]->getMessage());
$this->assertSame(400, $caught[0]->getCode(), 'The status channel must survive custom exception types');
$this->assertFalse($finalRan, 'A Request that throws during DI resolution must stop the route');
}
#[Test]
public function declaredDefaultExceptionCallbackRendersThroughTheBoundary(): void
{
// Se fuerza el valor declarado en la clase, no el que reasigna
// resetRouterState(): es justo el camino property default ->
// Synapsis::resolve(string callable) que debe seguir funcionando.
Router::$exceptionCallback = (new ReflectionClass(Router::class))
->getDefaultProperties()['exceptionCallback'];
Router::get('/default-exception', function (): void {
throw new Exception('rendered by the default handler', 422);
});
ob_start();
Router::apply('/default-exception');
$output = ob_get_clean();
$this->assertSame(422, http_response_code(), 'The declared default handler must derive the status from the code');
$this->assertStringContainsString('rendered by the default handler', $output);
$this->assertStringContainsString('#0', $output, 'The declared default handler must render the plain text trace');
}
#[Test]
#[RunInSeparateProcess]
public function declaredDefaultNotFoundCallbackRendersThe404Body(): void
{
Router::$notFoundCallback = (new ReflectionClass(Router::class))
->getDefaultProperties()['notFoundCallback'];
ob_start();
Router::apply('/no-such-route');
$output = ob_get_clean();
// Proceso aislado: defaultNotFound() emite header('HTTP/1.0 404 ...') y en
// PHP 8.5 eso hace que TODO http_response_code() posterior avise, sin forma
// de limpiar el estado en el mismo proceso. En CLI el header no toca
// http_response_code(), asi que la asercion util es el cuerpo renderizado.
$this->assertStringContainsString('Error 404 - Page Not Found', $output);
}
}
// phpcs:disable PSR1.Classes.ClassDeclaration.MultipleClasses
/**
* Fixtures locales de la prueba de simetría: un tipo de excepcion propio que
* un Request hijo lanza desde su onInvalid() reescrito.
*/
final class RequestRejected extends Exception
{
}
final class StrictSignup extends Request
{
public function rules(): array
{
return ['age' => 'required|min:18'];
}
public function onInvalid(string $error): never
{
throw new RequestRejected($error, 400);
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace Tests\Unit;
use Exception;
use Libs\Neuron;
use Libs\Synapsis;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* SynapsisTest - DuckBrain test harness
*
* Cubre la inyeccion de argumentos por nombre en resolve(): la clave de
* $named gana sobre el default de un opcional y sobre la resolucion DI por
* tipo, la coincidencia es por nombre y no por posicion, y las claves
* sobrantes se ignoran en silencio (riesgo documentado en design.md).
*/
final class SynapsisTest extends TestCase
{
#[Test]
public function namedValueWinsOverOptionalDefault(): void
{
$result = Synapsis::resolve(
fn ($config = 'default') => $config,
['config' => 'forced']
);
$this->assertSame('forced', $result, 'A named argument must take precedence over an optional default');
}
#[Test]
public function namedValueWinsOverTypeBasedResolution(): void
{
$real = new Exception('the real one', 422);
$result = Synapsis::resolve(
fn (Exception $exception) => $exception,
['exception' => $real]
);
$this->assertSame($real, $result, 'The injected instance must be the named one, not a container-built empty Exception');
}
#[Test]
public function namedParametersMatchByNameNotPosition(): void
{
$real = new Exception('matched by name');
[$b, $exception] = Synapsis::resolve(
fn (Neuron $b, Exception $exception) => [$b, $exception],
['exception' => $real]
);
$this->assertInstanceOf(Neuron::class, $b, 'Parameters without a named match must keep resolving through DI');
$this->assertSame($real, $exception, 'Position of the named parameter must not matter');
}
#[Test]
public function unmatchedNamedKeysAreIgnored(): void
{
$result = Synapsis::resolve(
fn ($x = 'untouched') => $x,
['foo' => 1]
);
$this->assertSame('untouched', $result, 'A key matching no parameter must be silently ignored');
}
#[Test]
public function renamedParameterFallsBackToContainerResolution(): void
{
$real = new Exception('never arrives');
// The handler renamed its parameter to $e, so the 'exception' key
// matches nothing and $e degrades to DI: a fresh empty Exception.
$result = Synapsis::resolve(
fn (Exception $e) => $e,
['exception' => $real]
);
$this->assertNotSame($real, $result, 'A renamed handler parameter must not receive the named value');
$this->assertSame('', $result->getMessage(), 'The degradation outcome is a container-built empty Exception');
}
}

View File

@@ -0,0 +1,192 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\Attributes\Test;
use Libs\Neuron;
use Libs\Validator;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
/**
* ValidatorTest - DuckBrain test harness
*
* Regression net de las reglas escalares, el batch validateList()
* (paro en el primer fallo + $lastFailed) y message(). Las reglas de
* archivo (file/image/mimes) requieren fixtures de $_FILES y se dejan
* para la suite multi-motor de integración.
*/
final class ValidatorTest extends TestCase
{
protected function setUp(): void
{
Validator::$lastFailed = '';
}
public static function scalarRuleProvider(): array
{
return [
'valid email' => ['email', 'kj@duckbrain.dev', true],
'invalid email' => ['email', 'not-an-email', false],
'valid url' => ['url', 'https://kj2.me', true],
'invalid url' => ['url', 'kj2.me', false],
'int from string' => ['int', '42', true],
'int with decimals' => ['int', '42.5', false],
'float' => ['float', '3.14', true],
'number e-notation' => ['number', '1e3', true],
'bool yes' => ['bool', 'yes', true],
'bool garbage' => ['bool', 'quizas', false],
'strict string' => ['string', '42', true],
'string rejects int' => ['string', 42, false],
'array' => ['array', [1], true],
'array rejects string' => ['array', 'x', false],
];
}
#[DataProvider('scalarRuleProvider')]
#[Test]
public function scalarRules(string $rule, mixed $value, bool $expected): void
{
$this->assertSame($expected, Validator::checkRule($value, $rule), "rule {$rule} with " . var_export($value, true));
}
#[Test]
public function existsAndRequiredDisagreeOnEmptyString(): void
{
$this->assertTrue(Validator::checkRule('', 'exists'));
$this->assertFalse(Validator::checkRule('', 'required'));
$this->assertFalse(Validator::checkRule(null, 'exists'));
}
#[Test]
public function sizeRulesMeasureStringNumberAndArray(): void
{
$this->assertTrue(Validator::checkRule('abc', 'min:3'));
$this->assertFalse(Validator::checkRule('abc', 'min:4'));
$this->assertTrue(Validator::checkRule(10, 'max:10'));
$this->assertTrue(Validator::checkRule([1, 2, 3], 'between:2,3'));
$this->assertTrue(Validator::checkRule('hey', 'size:3'));
$this->assertFalse(Validator::checkRule('hey', 'size:4'));
}
#[Test]
public function regexKeepsColonsAndCommasInsidePattern(): void
{
$this->assertTrue(Validator::checkRule('a,b', 'regex:/^a,b$/'));
$this->assertFalse(Validator::checkRule('a;b', 'regex:/^a,b$/'));
}
#[Test]
public function enumIsLooseComparison(): void
{
$this->assertTrue(Validator::checkRule('1', 'enum:1,2,3'));
$this->assertFalse(Validator::checkRule('9', 'enum:1,2,3'));
}
#[Test]
public function notNegatesNextRule(): void
{
$this->assertTrue(Validator::checkRule('3.5', 'not:int'));
$this->assertFalse(Validator::checkRule('42', 'not:int'));
}
#[Test]
public function parseRule(): void
{
$this->assertSame(['required', ''], Validator::parseRule('required'));
$this->assertSame(['enum', 'a,b'], Validator::parseRule('enum:a,b'));
$this->assertSame(['regex', '/^a,b$/'], Validator::parseRule('regex:/^a,b$/'));
}
#[Test]
public function validateListPassesFullyValidBatch(): void
{
$data = new Neuron(['username' => 'kj', 'email' => 'kj@duckbrain.dev']);
$this->assertTrue(Validator::validateList(
['username' => 'required|string|min:2', 'email' => 'required|email'],
$data
));
$this->assertSame('', Validator::$lastFailed);
}
#[Test]
public function validateListStopsAtFirstFailure(): void
{
$data = new Neuron(['email' => 'not-an-email', 'name' => null]);
$this->assertFalse(Validator::validateList(
['email' => 'required|email', 'name' => 'required'],
$data
));
$this->assertSame('email.email', Validator::$lastFailed);
}
#[Test]
public function validateListNullableSkipsRestWhenEmpty(): void
{
$rules = ['bio' => 'nullable|min:10'];
$this->assertTrue(Validator::validateList($rules, new Neuron(['bio' => null])));
$this->assertFalse(Validator::validateList($rules, new Neuron(['bio' => 'too-short'])));
}
#[Test]
public function validateListConfirmedUsesSiblingField(): void
{
$rules = ['password' => 'required|confirmed'];
$ok = new Neuron(['password' => 'secret', 'password_confirmation' => 'secret']);
$bad = new Neuron(['password' => 'secret', 'password_confirmation' => 'different']);
$this->assertTrue(Validator::validateList($rules, $ok));
$this->assertFalse(Validator::validateList($rules, $bad));
$this->assertSame('password.confirmed', Validator::$lastFailed);
}
#[Test]
public function messageBuildsHumanTextFromLastFailed(): void
{
$this->assertSame(
'The email must be a valid email address.',
Validator::message('email.email')
);
$this->assertSame(
'The age must be between 5 and 10.',
Validator::message('age.between:5,10')
);
$this->assertSame(
'The selected status is invalid. Allowed: a, b, c.',
Validator::message('status.enum:a,b,c')
);
$this->assertSame(
'The reason field is required when mode is other.',
Validator::message('reason.required_if:mode,other')
);
}
#[Test]
public function messageRespectsAttributesAndOverrides(): void
{
$this->assertSame(
'The user name field is required.',
Validator::message('username.required', [], ['username' => 'user name'])
);
$this->assertSame(
'custom',
Validator::message('username.min:3', ['username.min:3' => 'custom'])
);
}
}

56
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,56 @@
<?php
// Test bootstrap for the DuckBrain development harness.
// Run PHPUnit from the project root (autoload.php resolves config.php via cwd).
require_once __DIR__ . '/../vendor/autoload.php';
// Engine selection: DUCKBRAIN_TEST_DB=sqlite|mysql|pgsql (default: sqlite).
// sqlite uses an in-memory database, so the unit suite never needs services.
$engine = strtolower(getenv('DUCKBRAIN_TEST_DB') ?: 'sqlite');
$profiles = [
'sqlite' => [
'type' => 'sqlite',
'host' => 'localhost',
'name' => ':memory:',
'user' => '',
'pass' => '',
],
'mysql' => [
'type' => 'mysql',
'host' => '127.0.0.1',
'name' => 'duckbrain_test',
'user' => 'duckbrain',
'pass' => 'duckbrain',
],
'pgsql' => [
'type' => 'pgsql',
'host' => '127.0.0.1',
'name' => 'duckbrain_test',
'user' => 'duckbrain',
'pass' => 'duckbrain',
],
];
if (!isset($profiles[$engine])) {
fwrite(
STDERR,
"Invalid DUCKBRAIN_TEST_DB '{$engine}'. Expected one of: "
. implode(', ', array_keys($profiles)) . "\n"
);
exit(1);
}
// Define DB constants BEFORE config.php so the test database wins over the
// real configuration (config.php's define() warnings are suppressed by @).
// Each field can be overridden without losing the engine's other defaults.
$profile = $profiles[$engine];
define('DB_TYPE', $profile['type']);
define('DB_HOST', getenv('DUCKBRAIN_TEST_HOST') ?: $profile['host']);
define('DB_NAME', getenv('DUCKBRAIN_TEST_NAME') ?: $profile['name']);
define('DB_USER', getenv('DUCKBRAIN_TEST_USER') ?: $profile['user']);
define('DB_PASS', getenv('DUCKBRAIN_TEST_PASS') ?: $profile['pass']);
@require_once __DIR__ . '/../autoload.php';