Compare commits
163 Commits
b6555ee039
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b1d5e42bd | |||
| 26589062f5 | |||
| 5022fdac75 | |||
| 70b1016194 | |||
| 86ab8b979a | |||
| 9ddb00e719 | |||
| a534613d26 | |||
| 65380f37c6 | |||
| 5f3fc16735 | |||
| 146aed0db8 | |||
| 9dbfd7da8e | |||
| 3e7c367182 | |||
| 25db49f4bd | |||
| 9cb41d51d0 | |||
| d0c4b3c503 | |||
| 48d3ed6b3f | |||
| d9ac4c3f14 | |||
| d594aa3ec1 | |||
| bf03e10b54 | |||
| f1bda46723 | |||
| dbbde42fe7 | |||
| 7494ec5fda | |||
| c0e5cf79d0 | |||
| a4fcd21492 | |||
| a4611e8718 | |||
| 9027f36748 | |||
| 2ae9ef39f3 | |||
| ee0db0307e | |||
| 44cbe6bbdc | |||
| 31728af351 | |||
| 4845d14ef5 | |||
| aca4e732ed | |||
| f475c9125f | |||
| 4fe7314389 | |||
| 1f366830bc | |||
| 15df7c8d96 | |||
| b8dd1fd0f6 | |||
| 83c80bd4ab | |||
| b13c07d3c4 | |||
| b95747bfe0 | |||
| 01c5eceeb4 | |||
| ee9c109ed9 | |||
| 7eed69725d | |||
| e37035eed8 | |||
| 5312e5060e | |||
| 3c9db3ac53 | |||
| 2b5d19ecd5 | |||
| 53e862d126 | |||
| b6baeef32f | |||
| 159da01d5c | |||
| 3770dd6d9b | |||
| 76a69e5ef8 | |||
| c5a4967ae4 | |||
| c0b9f3851a | |||
| 78e199e300 | |||
| 8cd5e114dd | |||
| ce03bdb27d | |||
| b24598b118 | |||
| 5019b89cc0 | |||
| d030e8d30e | |||
| b2672ad92f | |||
| 4c57770b43 | |||
| 8e84450f95 | |||
| ece32b9bbe | |||
| a5cf9d239d | |||
| b9509c49ed | |||
| 6e433b4d06 | |||
| 892b3614ec | |||
| a0b544eae5 | |||
| 20fd78ab53 | |||
| f5411daaa5 | |||
| 7e7ec68fd7 | |||
| ac9a661bc0 | |||
| c8d7b69367 | |||
| 7f62e06ff9 | |||
| 674c9d5ff4 | |||
| 2f50532e13 | |||
| b41514a491 | |||
| a1a15f492c | |||
| b19e7d8789 | |||
| 4dfdb52519 | |||
| b0891885f9 | |||
| b282d5479f | |||
| c9f467345b | |||
| 0f46848d15 | |||
| b2cb8d6883 | |||
| e9126e7cde | |||
| 7169d2cae3 | |||
| 66b2bc0d91 | |||
| c8ab2aa2cc | |||
| 1e302a9ea7 | |||
| d0d0d4dc76 | |||
| 595e9c1316 | |||
| 45abea5301 | |||
| d441f001ec | |||
| 19da122e05 | |||
| 1a0164c8ed | |||
| ad9f8ec67d | |||
| 31c5c63952 | |||
| 6aef212350 | |||
| c600688725 | |||
| 3e27b1b7af | |||
| 73b7b8f72a | |||
| 7baad428ec | |||
| 3d2a607768 | |||
| df424ffab5 | |||
| daf7250882 | |||
| 05cd83fd10 | |||
| 6b470a181d | |||
| 7beb161d2b | |||
| 701caae7eb | |||
| 100bdfe006 | |||
| f1b79fdbc0 | |||
| 406f9a10a1 | |||
| cc3cb6be41 | |||
| 59fff2a586 | |||
| cd1685d2e7 | |||
| b85fb7e034 | |||
| a10308a8f6 | |||
| 9a1e5a2379 | |||
| fa60ec5bb4 | |||
| b294b1d627 | |||
| 27b5ed6015 | |||
| f9b2c678f7 | |||
| e294eaa7af | |||
| 186d640c8f | |||
| c65fcd27c8 | |||
| f5f803dde2 | |||
| 6a1085b224 | |||
| 64bcfa0b00 | |||
| cd01ab9e72 | |||
| e2094ccb4a | |||
| 341d7837a1 | |||
| 030e1079dc | |||
| af673a68b8 | |||
| eff0b86762 | |||
| 39a1f9d85a | |||
| d48f24ed98 | |||
| b326c8e1d0 | |||
| 1267d6b2f6 | |||
| a8d95cb55b | |||
| 06b7907f7c | |||
| 4d052efba6 | |||
| 11141a0eee | |||
| 3c8a21161f | |||
| 1bde430251 | |||
| 7d3d1615d5 | |||
| 404bd59569 | |||
| 53bdc92344 | |||
| ad7b78f427 | |||
| 38d2a90318 | |||
| 2411704662 | |||
| 08d92a2b81 | |||
| 8d47e10d7a | |||
| 2c7938a0c3 | |||
| c57ca191b1 | |||
| f4eabe6d54 | |||
| 83d9ba55aa | |||
| bad7bf0e65 | |||
| 5284be2b1b | |||
| 0db6e4a021 | |||
| afb4c914a1 | |||
| 4c4fe6f1f7 |
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/vendor/
|
||||||
|
/.phpunit.cache/
|
||||||
|
*.sqlite
|
||||||
|
/.publish/
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
<IfModule mod_rewrite.c>
|
<IfModule mod_rewrite.c>
|
||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
|
|
||||||
|
# Bloquear acceso a las carpetas vendor/ y src/
|
||||||
|
RewriteRule ^(vendor|src)/.*$ - [F,L]
|
||||||
|
|
||||||
# Handle Front Controller...
|
# Handle Front Controller...
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
|||||||
197
Makefile
Normal file
197
Makefile
Normal 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
14
autoload.php
Normal 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
15
composer.json
Normal 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
1970
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
config.php
15
config.php
@@ -1,10 +1,15 @@
|
|||||||
<?php
|
<?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', '');
|
define('SITE_URL', '');
|
||||||
|
|
||||||
|
// Advanced configuration
|
||||||
define('ROOT_DIR', __DIR__);
|
define('ROOT_DIR', __DIR__);
|
||||||
?>
|
define('ROOT_CORE', ROOT_DIR . '/src');
|
||||||
|
|||||||
29
index.php
29
index.php
@@ -1,24 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once('config.php');
|
|
||||||
|
|
||||||
// Incluir clases
|
use Libs\Loader;
|
||||||
spl_autoload_register(function ($className) {
|
use Libs\Router;
|
||||||
$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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Incluir routers
|
require_once('autoload.php');
|
||||||
$routers = glob(ROOT_DIR.'/src/Routers/*.php');
|
|
||||||
|
|
||||||
foreach($routers as $file){
|
// Router autoload
|
||||||
require_once($file);
|
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
25
phpunit.xml
Normal 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>
|
||||||
21
readme.md
21
readme.md
@@ -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
119
readme.org
Normal 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
|
||||||
@@ -1,38 +1,63 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
namespace Libs;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Database - DuckBrain
|
* Database - DuckBrain
|
||||||
*
|
*
|
||||||
* Clase diseñada para crear y devolver una única instancia mysqli (database).
|
* Class designed to create and return a single PDO instance (database).
|
||||||
* Depende de manera forzada de que estén definidas las constantes:
|
|
||||||
* dbhost, dbname, dbpass y dbuser
|
|
||||||
*
|
*
|
||||||
* @author KJ
|
* @author KJ
|
||||||
* @website https://kj2.me
|
* @website https://kj2.me
|
||||||
* @licence MIT
|
* @license MIT
|
||||||
*/
|
*/
|
||||||
|
class Database extends PDO
|
||||||
namespace Libs;
|
{
|
||||||
use mysqli;
|
private static array $databases = [];
|
||||||
|
|
||||||
class Database extends \mysqli {
|
|
||||||
static private $db;
|
|
||||||
|
|
||||||
private function __construct() {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Devuelve una instancia homogénea (singlenton) a la base de datos.
|
* Private constructor to prevent direct instantiation.
|
||||||
*
|
|
||||||
* @return mysqli
|
|
||||||
*/
|
*/
|
||||||
static public function getConnection() : mysqli {
|
private function __construct()
|
||||||
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.');
|
* 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
31
src/Libs/Loader.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
1201
src/Libs/Model.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
@@ -1,39 +1,59 @@
|
|||||||
<?php
|
<?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;
|
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 = []){
|
foreach ($data as $key => $value) {
|
||||||
$this->data = (array) $data;
|
$this->{$key} = $value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function __isset($index) {
|
/**
|
||||||
return isset($this->data[$index]);
|
* Magic method __get
|
||||||
}
|
*
|
||||||
|
* This method is called when an undefined property is accessed.
|
||||||
public function __get($index){
|
* It returns null instead of triggering an E_NOTICE.
|
||||||
return (isset($this->data[$index]) && $this->data[$index] != '')
|
*
|
||||||
? $this->data[$index] : null;
|
* @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
180
src/Libs/Request.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,48 +1,141 @@
|
|||||||
<?php
|
<?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;
|
namespace Libs;
|
||||||
|
|
||||||
class Router {
|
/**
|
||||||
private static $get = [];
|
* Router - DuckBrain
|
||||||
private static $post = [];
|
*
|
||||||
private static $put = [];
|
* Router library.
|
||||||
private static $delete = [];
|
* It strictly depends on the ROOT_DIR constant being defined
|
||||||
private static $last;
|
* and optionally on the SITE_URL constant being defined.
|
||||||
public static $notFoundCallback = 'Libs\Router::defaultNotFound';
|
*
|
||||||
|
* @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");
|
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
|
* @param string $path
|
||||||
* Ruta con pseudovariables.
|
* Route with pseudovariables.
|
||||||
*
|
*
|
||||||
* @param mixed $callback
|
* @param callable $callback
|
||||||
* Callback que será llamado cuando la ruta configurada en $path coincida.
|
* Callback that will be called when the route configured in $path matches.
|
||||||
*
|
*
|
||||||
* @return array
|
* @return array
|
||||||
* Arreglo con 2 índices:
|
* Array with 3 indices:
|
||||||
* path - Contiene la ruta con las pseudovariables reeplazadas por expresiones regulares.
|
* path - Contains the route with pseudovariables replaced by regular expressions.
|
||||||
* callback - Contiene el callback en formato Namespace\Clase::Método.
|
* 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);
|
preg_match_all('/{(\w+)}/s', $path, $matches, PREG_PATTERN_ORDER);
|
||||||
$paramNames = $matches[1];
|
$paramNames = $matches[1];
|
||||||
|
|
||||||
@@ -50,76 +143,75 @@ class Router {
|
|||||||
$path = preg_replace(
|
$path = preg_replace(
|
||||||
['/\\\{\w+\\\}/s'],
|
['/\\\{\w+\\\}/s'],
|
||||||
['([^\/]+)'],
|
['([^\/]+)'],
|
||||||
$path);
|
$path
|
||||||
|
);
|
||||||
if (!is_callable($callback)) {
|
|
||||||
$callback = 'Controllers\\'.$callback;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'path' => $path,
|
'path' => $path,
|
||||||
'callback' => [$callback],
|
'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"
|
* Ex: If the system URL is "https://example.com/duckbrain"
|
||||||
* entonces la ruta base sería "/duckbrain"
|
* then the base path would be "/duckbrain"
|
||||||
*
|
*
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
public static function basePath() : string {
|
public static function basePath(): string
|
||||||
if (defined('SITE_URL'))
|
{
|
||||||
return parse_url(SITE_URL, PHP_URL_PATH);
|
if (defined('SITE_URL') && !empty(SITE_URL)) {
|
||||||
|
return rtrim(parse_url(SITE_URL, PHP_URL_PATH), '/') . '/';
|
||||||
|
}
|
||||||
return str_replace($_SERVER['DOCUMENT_ROOT'], '/', ROOT_DIR);
|
return str_replace($_SERVER['DOCUMENT_ROOT'], '/', ROOT_DIR);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/**
|
||||||
* Redirije a una ruta relativa interna.
|
* Redirects to an internal relative path.
|
||||||
*
|
*
|
||||||
* @param string $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"
|
* Ex: If our system is at "https://example.com/duckbrain"
|
||||||
* llamamos a Router::redirect('/docs'), entonces seremos
|
* and we call Router::redirect('/docs'), we will be
|
||||||
* redirigidos a "https://ejemplo.com/duckbrain/docs".
|
* redirected to "https://example.com/duckbrain/docs".
|
||||||
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function redirect(string $path) {
|
public static function redirect(string $path): void
|
||||||
header('Location: '.static::basePath().substr($path,1));
|
{
|
||||||
|
header('Location: ' . static::basePath() . ltrim($path, '/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Añade un middleware a la última ruta usada.
|
* Adds a middleware to the last used route.
|
||||||
* Solo se puede usar un middleware a la vez.
|
* Only one middleware can be added at a time.
|
||||||
*
|
*
|
||||||
* @param mixed $callback
|
* @param callable $callback
|
||||||
* @param int $prioriry
|
* @param int|null $priority Optional priority for the middleware execution order.
|
||||||
*
|
*
|
||||||
* @return Router
|
* @return static
|
||||||
* Devuelve un enlace estático.
|
* Returns the current instance.
|
||||||
*/
|
*/
|
||||||
|
public static function middleware(callable $callback, ?int $priority = null): static
|
||||||
public static function middleware($callback, int $priority = null) : Router {
|
{
|
||||||
if (!isset(static::$last))
|
if (!isset(static::$last)) {
|
||||||
return new static();
|
return new static();
|
||||||
|
}
|
||||||
|
|
||||||
$method = static::$last[0];
|
$method = static::$last[0];
|
||||||
$index = static::$last[1];
|
$index = static::$last[1];
|
||||||
|
|
||||||
if (!is_callable($callback)) {
|
if (isset($priority) && $priority <= 0) {
|
||||||
$callback = 'Middlewares\\'.$callback;
|
$priority = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($priority) && $priority <= 0)
|
if (is_null($priority) || $priority >= count(static::$$method[$index]['callback'])) {
|
||||||
$priority = 1;
|
|
||||||
|
|
||||||
if (is_null($priority) || $priority >= count(static::$$method[$index]['callback']))
|
|
||||||
static::$$method[$index]['callback'][] = $callback;
|
static::$$method[$index]['callback'][] = $callback;
|
||||||
else {
|
} else {
|
||||||
static::$$method[$index]['callback'] = array_merge(
|
static::$$method[$index]['callback'] = array_merge(
|
||||||
array_slice(static::$$method[$index]['callback'], 0, $priority),
|
array_slice(static::$$method[$index]['callback'], 0, $priority),
|
||||||
[$callback],
|
[$callback],
|
||||||
@@ -131,185 +223,247 @@ class Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Neuron
|
* Temporarily applies a set of middlewares to routes defined within a given callback.
|
||||||
* Devuelve un objeto que contiene los atributos:
|
* The middlewares are only active for the duration of the callback execution
|
||||||
* post - Donde se encuentran los valores enviados por $_POST.
|
* and are reset afterwards.
|
||||||
* 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
|
|
||||||
*
|
*
|
||||||
|
* @param callable $middleware
|
||||||
* @param callable $callback
|
* @param callable $callback
|
||||||
* Callback que será llamado cuando la ruta configurada en $path coincida.
|
|
||||||
*
|
*
|
||||||
* @return static
|
* @return static
|
||||||
* Devuelve un enlace estático
|
|
||||||
*/
|
*/
|
||||||
public static function delete(string $path, $callback) : Router {
|
public static function withMiddleware(callable $middleware, callable $callback): static
|
||||||
static::$delete[] = static::parse($path, $callback);
|
{
|
||||||
static::$last = ['delete', count(static::$delete)-1];
|
$currentMiddlewares = static::$withMiddlewares;
|
||||||
|
array_unshift(static::$withMiddlewares, $middleware);
|
||||||
|
|
||||||
|
$callback();
|
||||||
|
|
||||||
|
static::$withMiddlewares = $currentMiddlewares; // Restore withMiddleware
|
||||||
|
|
||||||
return new static();
|
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
|
* @return string
|
||||||
*/
|
*/
|
||||||
public static function currentPath() : string {
|
public static function currentPath(): string
|
||||||
return preg_replace('/'.preg_quote(static::basePath(), '/').'/',
|
{
|
||||||
'/', strtok($_SERVER['REQUEST_URI'], '?'), 1);
|
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
|
* @param string|null $path (optional) Path to use. If not defined, it detects the current path.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Con middleware:
|
* @return void
|
||||||
* $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
|
|
||||||
*/
|
*/
|
||||||
public static function apply() {
|
public static function apply(?string $path = null): void
|
||||||
$path = static::currentPath();
|
{
|
||||||
$routers = [];
|
try {
|
||||||
switch ($_SERVER['REQUEST_METHOD']){ // Según el método selecciona un arreglo de routers configurados
|
$path = $path ?? static::currentPath();
|
||||||
case 'POST':
|
$routers = match ($_SERVER['REQUEST_METHOD']) { // Selects an array of routers based on the method
|
||||||
$routers = static::$post;
|
'POST' => static::$post,
|
||||||
break;
|
'PUT' => static::$put,
|
||||||
case 'PUT':
|
'PATCH' => static::$patch,
|
||||||
$routers = static::$put;
|
'DELETE' => static::$delete,
|
||||||
break;
|
default => static::$get
|
||||||
case 'DELETE':
|
};
|
||||||
$routers = static::$delete;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
$routers = static::$get;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$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
|
// Checking and storing the variable parameters of the route
|
||||||
if (preg_match_all('/^'.$router['path'].'\/?$/si',$path, $matches, PREG_PATTERN_ORDER)) {
|
if (isset($matches[1])) {
|
||||||
unset($matches[0]);
|
static::$params = new Neuron();
|
||||||
|
foreach ($matches as $index => $match) {
|
||||||
// Comprobando pseudo variables en la ruta
|
$paramName = $router['paramNames'][$index - 1];
|
||||||
if (isset($matches[1])) {
|
static::$params->{$paramName} = urldecode($match[0]);
|
||||||
foreach ($matches as $index => $match) {
|
}
|
||||||
$paramName = $router['paramNames'][$index-1];
|
|
||||||
$req->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
|
// If no router matches, call $notFoundCallBack
|
||||||
call_user_func_array(static::$notFoundCallback, [$req]);
|
Synapsis::resolve(static::$notFoundCallback);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
Synapsis::resolve(static::$exceptionCallback, ['exception' => $exception]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
|
|||||||
179
src/Libs/Synapsis.php
Normal file
179
src/Libs/Synapsis.php
Normal 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
1255
src/Libs/Validator.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,38 +1,164 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
|
||||||
* View - DuckBrain
|
|
||||||
*
|
|
||||||
* Manejador de vistas simplificado.
|
|
||||||
*
|
|
||||||
* @author KJ
|
|
||||||
* @website https://kj2.me
|
|
||||||
* @licence MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Libs;
|
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
|
* @param string $viewName Relative path and filename without extension.
|
||||||
* Ruta relativa y el nommbre sin extensión del archivo ubicado en src/Views
|
* @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
|
* @return void
|
||||||
* (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/".
|
|
||||||
*/
|
*/
|
||||||
public static function render(string $viewName, array $params = [], string $viewPath = null) {
|
public static function render(
|
||||||
$view = new Neuron($params);
|
string $viewName,
|
||||||
unset($params);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
|
|||||||
55
tests/Factories/Factory.php
Normal file
55
tests/Factories/Factory.php
Normal 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;
|
||||||
|
}
|
||||||
234
tests/Integration/ModelCrudTest.php
Normal file
234
tests/Integration/ModelCrudTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
100
tests/Integration/ModelFailureTest.php
Normal file
100
tests/Integration/ModelFailureTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
245
tests/Integration/ModelFilterTest.php
Normal file
245
tests/Integration/ModelFilterTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
145
tests/Integration/ModelJoinTest.php
Normal file
145
tests/Integration/ModelJoinTest.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
130
tests/Integration/ModelSearchTest.php
Normal file
130
tests/Integration/ModelSearchTest.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
95
tests/Integration/ModelTransactionTest.php
Normal file
95
tests/Integration/ModelTransactionTest.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
91
tests/Integration/SchemaSmokeTest.php
Normal file
91
tests/Integration/SchemaSmokeTest.php
Normal 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
20
tests/Models/Post.php
Normal 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
26
tests/Models/User.php
Normal 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
136
tests/TestCase.php
Normal 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
0
tests/Unit/.gitkeep
Normal file
57
tests/Unit/BootstrapTest.php
Normal file
57
tests/Unit/BootstrapTest.php
Normal 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
69
tests/Unit/NeuronTest.php
Normal 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
138
tests/Unit/RequestTest.php
Normal 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
317
tests/Unit/RouterTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
85
tests/Unit/SynapsisTest.php
Normal file
85
tests/Unit/SynapsisTest.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
192
tests/Unit/ValidatorTest.php
Normal file
192
tests/Unit/ValidatorTest.php
Normal 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
56
tests/bootstrap.php
Normal 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';
|
||||||
Reference in New Issue
Block a user