Merge pull request #535 from arabcoders/dev

New DBLayer & new Events system
This commit is contained in:
Abdulmohsen
2024-08-18 00:50:38 +03:00
committed by GitHub
52 changed files with 3740 additions and 53 deletions

55
FAQ.md
View File

@@ -351,9 +351,11 @@ $ mv /config/db/watchstate_v01-repaired.db /config/db/watchstate_v01.db
* tvrage://(id)
* anidb://(id)
* ytinforeader://(
id) [jellyfin](https://github.com/arabcoders/jf-ytdlp-info-reader-plugin) & [Emby](https://github.com/arabcoders/emby-ytdlp-info-reader-plugin). `(A yt-dlp info reader plugin)`.
id) [jellyfin](https://github.com/arabcoders/jf-ytdlp-info-reader-plugin) & [Emby](https://github.com/arabcoders/emby-ytdlp-info-reader-plugin).
`(A yt-dlp info reader plugin)`.
* cmdb://(
id) [jellyfin](https://github.com/arabcoders/jf-custom-metadata-db) & [Emby](https://github.com/arabcoders/emby-custom-metadata-db). `(User custom metadata database)`.
id) [jellyfin](https://github.com/arabcoders/jf-custom-metadata-db) & [Emby](https://github.com/arabcoders/emby-custom-metadata-db).
`(User custom metadata database)`.
---
@@ -739,7 +741,9 @@ If everything is working correctly you should see something like this previous j
----
### I keep receiving this warning in log `INFO: Ignoring [xxx] Episode range, and treating it as single episode. Backend says it covers [00-00]`?
### I keep receiving this warning in log
`INFO: Ignoring [xxx] Episode range, and treating it as single episode. Backend says it covers [00-00]`?
We recently added guard clause to prevent backends from sending possibly invalid episode ranges, as such if you see
this,
@@ -881,16 +885,53 @@ The feature first scan your entire history for reported media file paths. Depend
Lets says you have a media file `/media/series/season 1/episode 1.mkv` The scanner does the following:
* `/media` Does this path component exists? if not mark everything starting from `/media` as not found. if it exists simply move to the next component until we reach the end of the path.
* `/media` Does this path component exists? if not mark everything starting from `/media` as not found. if it exists
simply move to the next component until we reach the end of the path.
* `/media/series` Do same as above.
* `/media/series/season 1` Do same as above.
* `/media/series/season 1/episode 1.mkv` Do same as above.
Using this approach allow us to cache calls and reduce unnecessary calls to the filesystem. If you have for example `/media/seriesX/` with thousands of files,
and the path component `/media/seriesX` doesn't exists, we simply ignore everything that starts with `/media/seriesX/` and treat them as not found.
Using this approach allow us to cache calls and reduce unnecessary calls to the filesystem. If you have for example
`/media/seriesX/` with thousands of files,
and the path component `/media/seriesX` doesn't exists, we simply ignore everything that starts with `/media/seriesX/`
and treat them as not found.
This helps with slow stat calls in network shares, or cloud storage.
Everytime we do a stat call we cache it for 1 hour, so if we have multiple records reporting the same path, we only do the stat check once.
Everytime we do a stat call we cache it for 1 hour, so if we have multiple records reporting the same path, we only do
the stat check once.
---
### How to use hardware acceleration for video transcoding in the WebUI?
As the container is rootless, we cannot do the necessary changes to the container to enable hardware acceleration.
However, We do have the drivers and ffmpeg already installed and the CPU transcoding should work regardless. To enable
hardware acceleration You need to alter your `compose.yaml` file to mount the necessary devices to the container. Here
is an example of how to do it for debian based systems.
```yaml
services:
watchstate:
image: ghcr.io/arabcoders/watchstate:latest
# To change the user/group id associated with the tool change the following line.
user: "${UID:-1000}:${GID:-1000}"
group_add:
- "44" # Add video group to the container.
- "110" # Add render group to the container.
container_name: watchstate
restart: unless-stopped
ports:
- "8080:8080" # The port which will serve WebUI + API + Webhooks
volumes:
- ./data:/config:rw # mount current directory to container /config directory.
- /dev/dri:/dev/dri # mount the dri devices to the container.
- /storage/media:/media:ro # mount your media directory to the container.
```
This setup should work for VAAPI encoding in `x86_64` containers, for other architectures you need to adjust the
`/dev/dri` to match your hardware. There are currently an issue with nvidia h264_nvenc encoding, the alpine build for
`ffmpeg`doesn't include the codec.
Note: the tip about adding the group_add came from the user `binarypancakes` in discord.

View File

@@ -42,7 +42,9 @@
"dragonmantank/cron-expression": "^3.3.2",
"halaxa/json-machine": "^1.1.1",
"league/route": "^5.1.2",
"psy/psysh": "^0.11.22"
"psy/psysh": "^0.11.22",
"symfony/event-dispatcher": "^6.1.4",
"ramsey/uuid": "^4.5.1"
},
"suggest": {
"ext-sockets": "For UDP commincations."

461
composer.lock generated
View File

@@ -4,8 +4,68 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "e386d8f65a092fddeb914449ac6231a0",
"content-hash": "1c1f4ebab4be7bb510216bdcaec0175f",
"packages": [
{
"name": "brick/math",
"version": "0.12.1",
"source": {
"type": "git",
"url": "https://github.com/brick/math.git",
"reference": "f510c0a40911935b77b86859eb5223d58d660df1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/brick/math/zipball/f510c0a40911935b77b86859eb5223d58d660df1",
"reference": "f510c0a40911935b77b86859eb5223d58d660df1",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"php-coveralls/php-coveralls": "^2.2",
"phpunit/phpunit": "^10.1",
"vimeo/psalm": "5.16.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Brick\\Math\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Arbitrary-precision arithmetic library",
"keywords": [
"Arbitrary-precision",
"BigInteger",
"BigRational",
"arithmetic",
"bigdecimal",
"bignum",
"bignumber",
"brick",
"decimal",
"integer",
"math",
"mathematics",
"rational"
],
"support": {
"issues": "https://github.com/brick/math/issues",
"source": "https://github.com/brick/math/tree/0.12.1"
},
"funding": [
{
"url": "https://github.com/BenMorel",
"type": "github"
}
],
"time": "2023-11-29T23:19:16+00:00"
},
{
"name": "dragonmantank/cron-expression",
"version": "v3.3.3",
@@ -815,6 +875,56 @@
},
"time": "2021-11-05T16:47:00+00:00"
},
{
"name": "psr/event-dispatcher",
"version": "1.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/event-dispatcher.git",
"reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0",
"reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0",
"shasum": ""
},
"require": {
"php": ">=7.2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\EventDispatcher\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "http://www.php-fig.org/"
}
],
"description": "Standard interfaces for event handling.",
"keywords": [
"events",
"psr",
"psr-14"
],
"support": {
"issues": "https://github.com/php-fig/event-dispatcher/issues",
"source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0"
},
"time": "2019-01-08T18:20:26+00:00"
},
{
"name": "psr/http-client",
"version": "1.0.3",
@@ -1269,6 +1379,187 @@
},
"time": "2023-10-14T21:56:36+00:00"
},
{
"name": "ramsey/collection",
"version": "2.0.0",
"source": {
"type": "git",
"url": "https://github.com/ramsey/collection.git",
"reference": "a4b48764bfbb8f3a6a4d1aeb1a35bb5e9ecac4a5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ramsey/collection/zipball/a4b48764bfbb8f3a6a4d1aeb1a35bb5e9ecac4a5",
"reference": "a4b48764bfbb8f3a6a4d1aeb1a35bb5e9ecac4a5",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"captainhook/plugin-composer": "^5.3",
"ergebnis/composer-normalize": "^2.28.3",
"fakerphp/faker": "^1.21",
"hamcrest/hamcrest-php": "^2.0",
"jangregor/phpstan-prophecy": "^1.0",
"mockery/mockery": "^1.5",
"php-parallel-lint/php-console-highlighter": "^1.0",
"php-parallel-lint/php-parallel-lint": "^1.3",
"phpcsstandards/phpcsutils": "^1.0.0-rc1",
"phpspec/prophecy-phpunit": "^2.0",
"phpstan/extension-installer": "^1.2",
"phpstan/phpstan": "^1.9",
"phpstan/phpstan-mockery": "^1.1",
"phpstan/phpstan-phpunit": "^1.3",
"phpunit/phpunit": "^9.5",
"psalm/plugin-mockery": "^1.1",
"psalm/plugin-phpunit": "^0.18.4",
"ramsey/coding-standard": "^2.0.3",
"ramsey/conventional-commits": "^1.3",
"vimeo/psalm": "^5.4"
},
"type": "library",
"extra": {
"captainhook": {
"force-install": true
},
"ramsey/conventional-commits": {
"configFile": "conventional-commits.json"
}
},
"autoload": {
"psr-4": {
"Ramsey\\Collection\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ben Ramsey",
"email": "ben@benramsey.com",
"homepage": "https://benramsey.com"
}
],
"description": "A PHP library for representing and manipulating collections.",
"keywords": [
"array",
"collection",
"hash",
"map",
"queue",
"set"
],
"support": {
"issues": "https://github.com/ramsey/collection/issues",
"source": "https://github.com/ramsey/collection/tree/2.0.0"
},
"funding": [
{
"url": "https://github.com/ramsey",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/ramsey/collection",
"type": "tidelift"
}
],
"time": "2022-12-31T21:50:55+00:00"
},
{
"name": "ramsey/uuid",
"version": "4.7.6",
"source": {
"type": "git",
"url": "https://github.com/ramsey/uuid.git",
"reference": "91039bc1faa45ba123c4328958e620d382ec7088"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ramsey/uuid/zipball/91039bc1faa45ba123c4328958e620d382ec7088",
"reference": "91039bc1faa45ba123c4328958e620d382ec7088",
"shasum": ""
},
"require": {
"brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12",
"ext-json": "*",
"php": "^8.0",
"ramsey/collection": "^1.2 || ^2.0"
},
"replace": {
"rhumsaa/uuid": "self.version"
},
"require-dev": {
"captainhook/captainhook": "^5.10",
"captainhook/plugin-composer": "^5.3",
"dealerdirect/phpcodesniffer-composer-installer": "^0.7.0",
"doctrine/annotations": "^1.8",
"ergebnis/composer-normalize": "^2.15",
"mockery/mockery": "^1.3",
"paragonie/random-lib": "^2",
"php-mock/php-mock": "^2.2",
"php-mock/php-mock-mockery": "^1.3",
"php-parallel-lint/php-parallel-lint": "^1.1",
"phpbench/phpbench": "^1.0",
"phpstan/extension-installer": "^1.1",
"phpstan/phpstan": "^1.8",
"phpstan/phpstan-mockery": "^1.1",
"phpstan/phpstan-phpunit": "^1.1",
"phpunit/phpunit": "^8.5 || ^9",
"ramsey/composer-repl": "^1.4",
"slevomat/coding-standard": "^8.4",
"squizlabs/php_codesniffer": "^3.5",
"vimeo/psalm": "^4.9"
},
"suggest": {
"ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.",
"ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.",
"ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.",
"paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter",
"ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type."
},
"type": "library",
"extra": {
"captainhook": {
"force-install": true
}
},
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Ramsey\\Uuid\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "A PHP library for generating and working with universally unique identifiers (UUIDs).",
"keywords": [
"guid",
"identifier",
"uuid"
],
"support": {
"issues": "https://github.com/ramsey/uuid/issues",
"source": "https://github.com/ramsey/uuid/tree/4.7.6"
},
"funding": [
{
"url": "https://github.com/ramsey",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/ramsey/uuid",
"type": "tidelift"
}
],
"time": "2024-04-27T21:32:50+00:00"
},
{
"name": "symfony/cache",
"version": "v6.4.10",
@@ -1602,6 +1893,162 @@
],
"time": "2024-04-18T09:32:20+00:00"
},
{
"name": "symfony/event-dispatcher",
"version": "v6.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher.git",
"reference": "8d7507f02b06e06815e56bb39aa0128e3806208b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/8d7507f02b06e06815e56bb39aa0128e3806208b",
"reference": "8d7507f02b06e06815e56bb39aa0128e3806208b",
"shasum": ""
},
"require": {
"php": ">=8.1",
"symfony/event-dispatcher-contracts": "^2.5|^3"
},
"conflict": {
"symfony/dependency-injection": "<5.4",
"symfony/service-contracts": "<2.5"
},
"provide": {
"psr/event-dispatcher-implementation": "1.0",
"symfony/event-dispatcher-implementation": "2.0|3.0"
},
"require-dev": {
"psr/log": "^1|^2|^3",
"symfony/config": "^5.4|^6.0|^7.0",
"symfony/dependency-injection": "^5.4|^6.0|^7.0",
"symfony/error-handler": "^5.4|^6.0|^7.0",
"symfony/expression-language": "^5.4|^6.0|^7.0",
"symfony/http-foundation": "^5.4|^6.0|^7.0",
"symfony/service-contracts": "^2.5|^3",
"symfony/stopwatch": "^5.4|^6.0|^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\EventDispatcher\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/event-dispatcher/tree/v6.4.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-05-31T14:49:08+00:00"
},
{
"name": "symfony/event-dispatcher-contracts",
"version": "v3.5.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher-contracts.git",
"reference": "8f93aec25d41b72493c6ddff14e916177c9efc50"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/8f93aec25d41b72493c6ddff14e916177c9efc50",
"reference": "8f93aec25d41b72493c6ddff14e916177c9efc50",
"shasum": ""
},
"require": {
"php": ">=8.1",
"psr/event-dispatcher": "^1"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.5-dev"
},
"thanks": {
"name": "symfony/contracts",
"url": "https://github.com/symfony/contracts"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\EventDispatcher\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to dispatching event",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-04-18T09:32:20+00:00"
},
{
"name": "symfony/http-client",
"version": "v6.4.10",
@@ -3114,12 +3561,12 @@
"source": {
"type": "git",
"url": "https://github.com/Roave/SecurityAdvisories.git",
"reference": "fe2777b484817ebbbe50ad685af7525560198c59"
"reference": "251a4f1fefcc6e6cc90d50514fee6b6e3745cb3e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe2777b484817ebbbe50ad685af7525560198c59",
"reference": "fe2777b484817ebbbe50ad685af7525560198c59",
"url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/251a4f1fefcc6e6cc90d50514fee6b6e3745cb3e",
"reference": "251a4f1fefcc6e6cc90d50514fee6b6e3745cb3e",
"shasum": ""
},
"conflict": {
@@ -3285,7 +3732,7 @@
"ezsystems/ezplatform-graphql": ">=1.0.0.0-RC1-dev,<1.0.13|>=2.0.0.0-beta1,<2.3.12",
"ezsystems/ezplatform-kernel": "<1.2.5.1-dev|>=1.3,<1.3.35",
"ezsystems/ezplatform-rest": ">=1.2,<=1.2.2|>=1.3,<1.3.8",
"ezsystems/ezplatform-richtext": ">=2.3,<2.3.7.1-dev",
"ezsystems/ezplatform-richtext": ">=2.3,<2.3.7.1-dev|>=3.3,<3.3.40",
"ezsystems/ezplatform-solr-search-engine": ">=1.7,<1.7.12|>=2,<2.0.2|>=3.3,<3.3.15",
"ezsystems/ezplatform-user": ">=1,<1.0.1",
"ezsystems/ezpublish-kernel": "<6.13.8.2-dev|>=7,<7.5.31",
@@ -3366,6 +3813,7 @@
"hyn/multi-tenant": ">=5.6,<5.7.2",
"ibexa/admin-ui": ">=4.2,<4.2.3|>=4.6.0.0-beta1,<4.6.9",
"ibexa/core": ">=4,<4.0.7|>=4.1,<4.1.4|>=4.2,<4.2.3|>=4.5,<4.5.6|>=4.6,<4.6.2",
"ibexa/fieldtype-richtext": ">=4.6,<4.6.10",
"ibexa/graphql": ">=2.5,<2.5.31|>=3.3,<3.3.28|>=4.2,<4.2.3",
"ibexa/post-install": "<=1.0.4",
"ibexa/solr": ">=4.5,<4.5.4",
@@ -3608,6 +4056,7 @@
"pubnub/pubnub": "<6.1",
"pusher/pusher-php-server": "<2.2.1",
"pwweb/laravel-core": "<=0.3.6.0-beta",
"pxlrbt/filament-excel": "<2.3.3",
"pyrocms/pyrocms": "<=3.9.1",
"qcubed/qcubed": "<=3.1.1",
"quickapps/cms": "<=2.0.0.0-beta2",
@@ -3921,7 +4370,7 @@
"type": "tidelift"
}
],
"time": "2024-08-08T21:04:55+00:00"
"time": "2024-08-14T19:05:08+00:00"
},
{
"name": "sebastian/cli-parser",

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
use App\Backends\Emby\EmbyClient;
use App\Backends\Jellyfin\JellyfinClient;
use App\Backends\Plex\PlexClient;
use App\Commands\Events\DispatchCommand;
use App\Commands\State\BackupCommand;
use App\Commands\State\ExportCommand;
use App\Commands\State\ImportCommand;
@@ -312,6 +313,31 @@ return (function () {
'timer' => $checkTaskTimer((string)env('WS_CRON_REQUESTS_AT', '*/2 * * * *'), '*/2 * * * *'),
'args' => env('WS_CRON_REQUESTS_ARGS', '-v --no-stats'),
],
DispatchCommand::TASK_NAME => [
'command' => DispatchCommand::ROUTE,
'name' => DispatchCommand::TASK_NAME,
'info' => 'Dispatch queued events to their respective listeners.',
'enabled' => true,
'timer' => '* * * * *',
'args' => '-v',
],
],
];
$config['events'] = [
'logfile' => ag($config, 'tmpDir') . '/logs/events.' . $logDateFormat . '.log',
'listeners' => [
'cache' => new DateInterval(env('WS_EVENTS_LISTENERS_CACHE', 'PT1M')),
'file' => env('APP_EVENTS_FILE', function () use ($config): string|null {
$file = ag($config, 'path') . '/config/events.php';
return file_exists($file) ? $file : null;
}),
'locations' => [
__DIR__ . '/../src/API/',
__DIR__ . '/../src/Backends/',
__DIR__ . '/../src/Commands/',
__DIR__ . '/../src/Listeners/',
]
],
];

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
use App\Libs\Config;
use App\Libs\Container;
use App\Libs\Database\DatabaseInterface as iDB;
use App\Libs\Database\DBLayer;
use App\Libs\Database\PDO\PDOAdapter;
use App\Libs\Entity\StateEntity;
use App\Libs\Entity\StateInterface;
@@ -19,6 +20,7 @@ use App\Libs\Mappers\ImportInterface as iImport;
use App\Libs\QueueRequests;
use App\Libs\Uri;
use Monolog\Logger;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\UriInterface;
use Psr\Log\LoggerInterface as iLogger;
use Psr\SimpleCache\CacheInterface;
@@ -29,6 +31,7 @@ use Symfony\Component\Cache\Psr16Cache;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpClient\CurlHttpClient;
use Symfony\Component\Yaml\Yaml;
use Symfony\Contracts\HttpClient\HttpClientInterface;
@@ -217,6 +220,13 @@ return (function (): array {
],
],
DBLayer::class => [
'class' => fn(PDO $pdo): DBLayer => new DBLayer($pdo),
'args' => [
PDO::class,
],
],
MemoryMapper::class => [
'class' => function (iLogger $logger, iDB $db, CacheInterface $cache): iImport {
return (new MemoryMapper(logger: $logger, db: $db, cache: $cache))
@@ -249,5 +259,10 @@ return (function (): array {
MemoryMapper::class
],
],
EventDispatcherInterface::class => [
'class' => fn(): EventDispatcher => new EventDispatcher(),
],
];
})();

View File

@@ -17,7 +17,10 @@ const props = defineProps({
const content = ref('')
const api_url = useStorage('api_url', '')
onMounted(() => fetch(`${api_url.value}${props.file}`).then(response => response.text()).then(text => {
onMounted(async () => {
const response = await fetch(`${api_url.value}${props.file}?_=${Date.now()}`)
const text = await response.text()
marked.use({
gfm: true,
renderer: {
@@ -43,7 +46,8 @@ onMounted(() => fetch(`${api_url.value}${props.file}`).then(response => response
},
...baseUrl(api_url.value),
});
content.value = marked.parse(text)
}));
});
</script>

View File

@@ -50,7 +50,7 @@
<div class="is-capitalized card-header-title">
{{ task.name }}
</div>
<span class="card-header-icon" v-tooltip="'Enable/Disable Task.'">
<span class="card-header-icon" v-tooltip="'Enable/Disable Task.'" v-if="task.allow_disable">
<input :id="task.name" type="checkbox" class="switch is-success" :checked="task.enabled"
@change="toggleTask(task)">
<label :for="task.name"></label>
@@ -70,13 +70,21 @@
</div>
<div class="column is-6 has-text-left">
<strong class="is-hidden-mobile">Timer:&nbsp;</strong>
<NuxtLink class="has-tooltip" :to='makeEnvLink(`WS_CRON_${task.name.toUpperCase()}_AT`, task.timer)'>
<span v-if="!task.allow_disabled" class="is-unselectable">
{{ task.timer }}
</span>
<NuxtLink v-else class="has-tooltip"
:to='makeEnvLink(`WS_CRON_${task.name.toUpperCase()}_AT`, task.timer)'>
{{ task.timer }}
</NuxtLink>
</div>
<div class="column is-6 has-text-right" v-if="task.args">
<strong class="is-hidden-mobile">Args:&nbsp;</strong>
<NuxtLink class="has-tooltip" :to='makeEnvLink(`WS_CRON_${task.name.toUpperCase()}_ARGS`, task.args)'>
<span v-if="!task.allow_disabled" class="is-unselectable">
{{ task.args }}
</span>
<NuxtLink v-else class="has-tooltip"
:to='makeEnvLink(`WS_CRON_${task.name.toUpperCase()}_ARGS`, task.args)'>
{{ task.args }}
</NuxtLink>
</div>
@@ -162,7 +170,7 @@
import 'assets/css/bulma-switch.css'
import moment from 'moment'
import request from '~/utils/request'
import {awaitElement, makeConsoleCommand, notification, TOOLTIP_DATE_FORMAT} from '~/utils/index'
import {awaitElement, makeConsoleCommand, notification, parse_api_response, TOOLTIP_DATE_FORMAT} from '~/utils/index'
import cronstrue from 'cronstrue'
import Message from '~/components/Message'
import {useStorage} from '@vueuse/core'
@@ -199,11 +207,21 @@ onMounted(async () => await loadContent())
const toggleTask = async task => {
try {
const keyName = `WS_CRON_${task.name.toUpperCase()}`
await request(`/system/env/${keyName}`, {
const oldState = task.enabled
const update = await request(`/system/env/${keyName}`, {
method: 'POST',
body: JSON.stringify({"value": !task.enabled})
})
if (200 !== update.status) {
const json = await parse_api_response(update)
notification('error', 'Error', `Failed to toggle task '${task.name}' status. ${json.error.message}`)
tasks.value[tasks.value.findIndex(b => b.name === task.name)].enabled = oldState
return
}
const response = await request(`/tasks/${task.name}`)
tasks.value[tasks.value.findIndex(b => b.name === task.name)] = await response.json()
} catch (e) {

View File

@@ -465,6 +465,14 @@ const basename = (path, ext = '') => {
return base
}
const parse_api_response = async r => {
try {
return await r.json()
} catch (e) {
return {error: {code: r.status, message: r.statusText}}
}
}
export {
r,
ag_set,
@@ -485,5 +493,6 @@ export {
TOOLTIP_DATE_FORMAT,
makeSecret,
explode,
basename
basename,
parse_api_response,
}

View File

@@ -0,0 +1,22 @@
-- # migrate_up
CREATE TABLE `events`
(
`id` char(36) NOT NULL,
`status` tinyint(1) NOT NULL DEFAULT 0,
`event` varchar(255) NOT NULL,
`event_data` longtext NOT NULL DEFAULT '{}',
`options` longtext NOT NULL DEFAULT '{}',
`attempts` tinyint(1) NOT NULL DEFAULT 0,
`logs` longtext NOT NULL DEFAULT '{}',
`created_at` datetime NOT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
);
CREATE INDEX "events_event" ON "events" ("event");
CREATE INDEX "events_status" ON "events" ("status");
-- # migrate_down
DROP TABLE IF EXISTS "events";

View File

@@ -208,7 +208,8 @@ final class Webhooks
$this->cache->set('requests', $items, new DateInterval('P3D'));
if (false === $metadataOnly && true === $entity->hasPlayProgress() && !$entity->isWatched()) {
$pEnabled = (bool)env('WS_CRON_PROGRESS', false);
if ($pEnabled && false === $metadataOnly && true === $entity->hasPlayProgress() && !$entity->isWatched()) {
$progress = $this->cache->get('progress', []);
$progress[str_replace($itemId, ':tainted@', ':untainted@')] = $entity;
$this->cache->set('progress', $progress, new DateInterval('P3D'));
@@ -223,7 +224,7 @@ final class Webhooks
'type' => $entity->type,
'played' => $entity->isWatched() ? 'Yes' : 'No',
'queue_id' => $itemId,
'progress' => $entity->hasPlayProgress() ? $entity->getPlayProgress() : null,
'progress' => $pEnabled && $entity->hasPlayProgress() ? $entity->getPlayProgress() : null,
]
]
);

View File

@@ -27,7 +27,7 @@ final class Index
* @throws InvalidArgumentException
*/
#[Get(self::URL . '[/]', name: 'tasks.index')]
public function tasksIndex(iRequest $request): iResponse
public function tasksIndex(): iResponse
{
$queuedTasks = $this->cache->get('queued_tasks', []);
$response = [
@@ -39,6 +39,8 @@ final class Index
foreach (TasksCommand::getTasks() as $task) {
$task = self::formatTask($task);
$task['queued'] = in_array(ag($task, 'name'), $queuedTasks);
$response['tasks'][] = $task;
}
@@ -49,12 +51,8 @@ final class Index
* @throws InvalidArgumentException
*/
#[Route(['GET', 'POST', 'DELETE'], self::URL . '/{id:[a-zA-Z0-9_-]+}/queue[/]', name: 'tasks.task.queue')]
public function taskQueue(iRequest $request, array $args = []): iResponse
public function taskQueue(iRequest $request, string $id): iResponse
{
if (null === ($id = ag($args, 'id'))) {
return api_error('No id was given.', Status::BAD_REQUEST);
}
$task = TasksCommand::getTasks($id);
if (empty($task)) {
@@ -85,12 +83,8 @@ final class Index
* @throws InvalidArgumentException
*/
#[Get(self::URL . '/{id:[a-zA-Z0-9_-]+}[/]', name: 'tasks.task.view')]
public function taskView(iRequest $request, array $args = []): iResponse
public function taskView(string $id): iResponse
{
if (null === ($id = ag($args, 'id'))) {
return api_error('No id was given.', Status::BAD_REQUEST);
}
$task = TasksCommand::getTasks($id);
if (empty($task)) {
@@ -127,6 +121,9 @@ final class Index
$item['command'] = get_debug_type($item['command']);
}
$ff = getEnvSpec('WS_CRON_' . strtoupper(ag($task, 'name')));
$item['allow_disable'] = !empty($ff);
if (true === $isEnabled) {
try {
$item['next_run'] = makeDate($timer->getNextRunDate());

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Commands\Events;
use App\Command;
use App\Libs\Attributes\Route\Cli;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[Cli(command: self::ROUTE)]
final class CacheCommand extends Command
{
public const string ROUTE = 'events:cache';
protected function configure(): void
{
$this->setName(self::ROUTE)
->setDescription('Force cache invalidation for the events registrar.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
registerEvents(ignoreCache: true);
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Commands\Events;
use App\Command;
use App\Libs\Attributes\Route\Cli;
use App\Libs\Events\DataEvent;
use App\Model\Events\Event;
use App\Model\Events\EventsRepository;
use App\Model\Events\EventsTable;
use App\Model\Events\EventStatus as Status;
use Psr\EventDispatcher\EventDispatcherInterface as iDispatcher;
use Psr\Log\LoggerInterface as iLogger;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Throwable;
#[Cli(command: self::ROUTE)]
final class DispatchCommand extends Command
{
public const string TASK_NAME = 'Dispatch';
public const string ROUTE = 'events:dispatch';
public function __construct(
private readonly iDispatcher $dispatcher,
private readonly EventsRepository $repo,
private iLogger $logger,
) {
parent::__construct(null);
}
protected function configure(): void
{
$this->setName(self::ROUTE)
->addOption('id', 'i', InputOption::VALUE_REQUIRED, 'Force run this event.')
->addOption('reset', 'r', InputOption::VALUE_NONE, 'Reset event logs.')
->setDescription('Run queued events.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
registerEvents();
$id = $input->getOption('id');
if (null !== $id) {
if (null === ($event = $this->repo->findById($id))) {
$this->logger->error(r("Event with id '{id}' not found.", ['id' => $id]));
return self::FAILURE;
}
if ($input->getOption('reset')) {
$event->logs = [];
}
$this->runEvent($event);
return self::SUCCESS;
}
return $this->runEvents();
}
protected function runEvents(): int
{
$events = $this->repo->findAll([EventsTable::COLUMN_STATUS => Status::PENDING->value]);
if (count($events) < 1) {
$this->logger->debug('No pending queued events found.');
return self::SUCCESS;
}
assert($this->dispatcher instanceof EventDispatcher);
foreach ($events as $event) {
$this->runEvent($event);
}
return self::SUCCESS;
}
private function runEvent(Event $event): void
{
try {
$message = "Dispatching Event: '{event}' queued at '{date}'.";
$log_data = [
'event' => $event->event,
'date' => makeDate($event->created_at),
];
$event->logs[] = r($message, $log_data);
if (count($event->event_data) > 0) {
$log_data['data'] = $event->event_data;
}
$this->logger->info($message, $log_data);
$event->status = Status::RUNNING;
$event->updated_at = (string)makeDate();
$event->attempts += 1;
$this->repo->save($event);
$ref = new DataEvent($event);
$this->dispatcher->dispatch($ref, $event->event);
$event->status = Status::SUCCESS;
$event->updated_at = (string)makeDate();
$event->logs[] = r("Event '{event}' was dispatched.", ['event' => $event->event]);
$this->repo->save($event);
} catch (Throwable $e) {
$errorLog = r("Failed to dispatch event: '{event}'. {error}", [
'event' => ag($event, 'event'),
'error' => $e->getMessage(),
]);
$event->logs[] = $errorLog;
array_push($event->logs, ...$e->getTrace());
$event->status = Status::FAILED;
$event->updated_at = (string)makeDate();
$this->repo->save($event);
$this->logger->error($errorLog);
}
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Commands\Events;
use App\Command;
use App\Libs\Attributes\Route\Cli;
use Psr\EventDispatcher\EventDispatcherInterface as iDispatcher;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\EventDispatcher\EventDispatcher;
#[Cli(command: self::ROUTE)]
final class ListenersCommand extends Command
{
public const string ROUTE = 'events:listeners';
public function __construct(private readonly iDispatcher $dispatcher)
{
parent::__construct(null);
}
protected function configure(): void
{
$this->setName(self::ROUTE)->setDescription('Show registered events Listeners.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$mode = $input->getOption('output');
$keys = [];
assert($this->dispatcher instanceof EventDispatcher);
foreach ($this->dispatcher->getListeners() as $key => $val) {
$listeners = [];
foreach ($val as $listener) {
$listeners[] = get_debug_type($listener);
}
$keys[$key] = join(', ', $listeners);
}
if ('table' === $mode) {
$list = [];
foreach ($keys as $key => $val) {
$list[] = ['Event' => $key, 'value' => $val];
}
$keys = $list;
}
$this->displayContent($keys, $output, $mode);
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Commands\Events;
use App\Command;
use App\Libs\Attributes\Route\Cli;
use App\Model\Events\EventsRepository;
use App\Model\Events\EventsTable;
use App\Model\Events\EventStatus as Status;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
#[Cli(command: self::ROUTE)]
final class QueuedCommand extends Command
{
public const string ROUTE = 'events:queued';
public function __construct(private readonly EventsRepository $repo)
{
parent::__construct(null);
}
protected function configure(): void
{
$this->setName(self::ROUTE)
->addOption('all', 'a', InputOption::VALUE_NONE, 'Show all.')
->setDescription('Show queued events.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$filter = [
EventsTable::COLUMN_STATUS => Status::PENDING->value
];
if ($input->getOption('all')) {
$filter = [];
}
$events = $this->repo->findAll($filter);
$mode = $input->getOption('output');
if ('table' === $mode) {
$list = [];
foreach ($events as $event) {
$list[] = [
'id' => $event->id,
'event' => $event->event,
'added' => $event->created_at,
'status' => ucfirst(strtolower($event->status->name)),
'Dispatched' => $event->updated_at ?? 'N/A',
];
}
$keys = $list;
} else {
$keys = array_map(fn($event) => $event->getAll(), $events);
}
$this->displayContent($keys, $output, $mode);
return self::SUCCESS;
}
}

View File

@@ -7,7 +7,9 @@ namespace App\Commands\System;
use App\Command;
use App\Libs\Attributes\Route\Cli;
use App\Libs\Config;
use Psr\Log\LoggerInterface;
use App\Libs\Database\DBLayer;
use App\Model\Events\EventsTable;
use Psr\Log\LoggerInterface as iLogger;
use SplFileInfo;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
@@ -29,9 +31,9 @@ final class PruneCommand extends Command
/**
* Class Constructor.
*
* @param LoggerInterface $logger The logger implementation used for logging.
* @param iLogger $logger The logger implementation used for logging.
*/
public function __construct(private LoggerInterface $logger)
public function __construct(private readonly iLogger $logger, private readonly DBLayer $db)
{
parent::__construct();
}
@@ -181,6 +183,19 @@ final class PruneCommand extends Command
}
}
$this->cleanUp();
return self::SUCCESS;
}
private function cleanUp()
{
$stmt = $this->db->delete('events', [
EventsTable::COLUMN_CREATED_AT => [DBLayer::IS_LOWER_THAN_OR_EQUAL, strtotime('-7 DAYS')]
]);
$count = $stmt->rowCount();
if ($count > 1) {
$this->logger->info("Pruned '{count}' events.", ['count' => $count]);
}
}
}

View File

@@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace App\Libs\Attributes\Scanner;
use Attribute;
use Closure;
use InvalidArgumentException;
use Iterator;
use PhpToken;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ReflectionAttribute;
use ReflectionClass;
use RuntimeException;
use SplHeap;
use Throwable;
final class Attributes implements LoggerAwareInterface
{
use LoggerAwareTrait;
/**
* Scan for attributes in given directories.
*
* @param array<string> $dirs List of directories to scan for php files.
* @param bool $allowNonInvokable Allow non-invokable classes to be listed.
*
* @return self
*/
public static function scan(array $dirs, bool $allowNonInvokable = false): self
{
return new self($dirs, $allowNonInvokable);
}
private function __construct(private readonly array $dirs, private bool $allowNonInvokable)
{
}
/**
* Scan for attributes.
*
* @param Object|class-string $attribute Attribute to search for.
* @param Closure|null $filter Filter to apply on returned data.
*
* @return array<array-key,Item> List of attributes found. Empty array if none found.
* @throws \ReflectionException
*/
public function for(object|string $attribute, Closure|null $filter = null): array
{
$references = [];
$class = new ReflectionClass($attribute);
$hasAttributes = $class->getAttributes(Attribute::class);
if (empty($hasAttributes)) {
throw new InvalidArgumentException(sprintf("The given class '%s' isn't a attribute.", $attribute));
}
if (is_string($attribute)) {
if (!$class->isInstantiable()) {
throw new InvalidArgumentException(sprintf("Class '%s' is not instantiable.", $attribute));
}
$attribute = $class->newInstanceWithoutConstructor();
}
foreach ($this->dirs as $path) {
$this->logger?->debug("Scanning '{dir}' for '{attr}' attributes.", [
'dir' => $path,
'attr' => $attribute::class,
]);
array_push($references, ...$this->lookup($path, $attribute, $filter));
}
return $references;
}
/**
* Lookup for attributes in given directory.
*
* @param string $dir Directory to scan.
* @param Object $attribute Attribute to search for.
* @param Closure|null $filter Filter to apply on returned data.
*
* @return array<array-key,array{callable:string}> List of attributes found. Empty array if none found.
*/
private function lookup(string $dir, object $attribute, Closure|null $filter = null): array
{
$classes = $callables = [];
$it = $this->getSorter(
new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
)
);
foreach ($it as $file) {
if (!$file->isFile() || 'php' !== $file->getExtension()) {
continue;
}
$class = $this->parse((string)$file);
if (empty($class)) {
continue;
}
array_push($classes, ...$class);
}
foreach ($classes as $className) {
if (!class_exists($className)) {
$this->logger?->warning(sprintf("Class '%s' not found.", $className));
continue;
}
array_push(
$callables,
...$this->find(new ReflectionClass($className), $attribute, $filter)
);
}
return $callables;
}
/**
* Find attributes in class.
*
* @param ReflectionClass $class Class to search.
* @param Object $attribute Attribute to search for.
* @param Closure|null $filter Filter to apply on returned data.
*
* @return array<array-key,array{callable:string}> List of attributes found. Empty array if none found.
*/
private function find(ReflectionClass $class, object $attribute, Closure|null $filter = null): array
{
$routes = [];
$attributes = $class->getAttributes($attribute::class, ReflectionAttribute::IS_INSTANCEOF);
$invokable = false;
foreach ($class->getMethods() as $method) {
if ($method->getName() === '__invoke') {
$invokable = true;
}
}
// -- for invokable classes.
foreach ($attributes as $attrRef) {
try {
$attributeClass = $attrRef->newInstance();
} catch (Throwable) {
continue;
}
if (!$attributeClass instanceof $attribute) {
continue;
}
if (false === $invokable && !$this->allowNonInvokable) {
throw new InvalidArgumentException(
sprintf(
"Found attribute '%s' on non-invokable class. '%s'.",
$attributeClass->pattern,
$class->getName()
)
);
}
$item = [
'target' => Target::IS_CLASS,
'attribute' => $attributeClass::class,
'callable' => $class->getName(),
'data' => [],
];
if (null !== $filter) {
$filtered = $filter($attributeClass, $item);
if (!empty($filtered)) {
$item['data'] = $filtered;
}
} else {
$item['data'] = get_object_vars($attributeClass);
}
$routes[] = new Item(...$item);
}
foreach ($class->getMethods() as $method) {
$attributes = $method->getAttributes($attribute::class, ReflectionAttribute::IS_INSTANCEOF);
foreach ($attributes as $attrRef) {
try {
$attributeClass = $attrRef->newInstance();
} catch (Throwable) {
continue;
}
if (!$attributeClass instanceof $attribute) {
continue;
}
$call = '__invoke' === $method->getName() ? $class->getName() : [$class->getName(), $method->getName()];
$item = [
'target' => Target::IS_METHOD,
'attribute' => $attributeClass::class,
'callable' => $call,
'data' => [],
];
if (null !== $filter) {
$filtered = $filter($attributeClass, $item);
if (!empty($filtered)) {
$item['data'] = $filtered;
}
} else {
$item['data'] = get_object_vars($attributeClass);
}
$routes[] = new Item(...$item);
}
}
return $routes;
}
/**
* Parse file for classes.
*
* @param string $file File to parse.
*
* @return array List of classes. Empty array if none found.
*/
public function parse(string $file): array
{
$classes = [];
$namespace = [];
try {
$contents = file_get_contents($file);
$tokens = PhpToken::tokenize($contents, TOKEN_PARSE);
$count = count($tokens);
} catch (InvalidArgumentException $e) {
throw new RuntimeException(sprintf("Unable to read/parse '%s'. %s", $file, $e->getMessage()));
}
foreach ($tokens as $index => $token) {
if ($token->is(T_NAMESPACE)) {
for ($j = $index + 1; $j < $count; $j++) {
if ($tokens[$j]->is(T_STRING)) {
$namespace = $tokens[$j]->text;
break;
}
if ($tokens[$j]->is(T_NAME_QUALIFIED)) {
$namespace = $tokens[$j]->text;
break;
}
if (';' === $tokens[$j]->getTokenName()) {
break;
}
}
}
if ($token->is(T_CLASS)) {
for ($j = $index + 1; $j < $count; $j++) {
if ($tokens[$j]->is(T_WHITESPACE)) {
continue;
}
if ($tokens[$j]->is(T_STRING)) {
$classes[] = $namespace . '\\' . $tokens[$j]->text;
} else {
break;
}
}
}
}
return count($classes) >= 1 ? $classes : [];
}
/**
* Get sorter for given iterator.
*
* @param Iterator $it Iterator to sort.
*
* @return SplHeap<RecursiveDirectoryIterator> Sorted iterator.
*/
private function getSorter(Iterator $it): SplHeap
{
return new class($it) extends SplHeap {
public function __construct(Iterator $iterator)
{
foreach ($iterator as $item) {
$this->insert($item);
}
}
public function compare($value1, $value2): int
{
return strcmp($value2->getRealpath(), $value1->getRealpath());
}
};
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Libs\Attributes\Scanner;
use App\Libs\Container;
use Closure;
final readonly class Item
{
public function __construct(
public Target $target,
public string $attribute,
public string|array|Closure $callable,
public array $data = []
) {
}
public function getCallable(): string|array|Closure
{
return $this->callable;
}
public function call(...$args): mixed
{
$callable = $this->callable;
if (is_string($callable) && str_contains($callable, '::')) {
$callable = explode('::', $callable);
}
if (is_array($callable) && isset($callable[0]) && is_object($callable[0])) {
$callable = [$callable[0], $callable[1]];
}
if (is_array($callable) && isset($callable[0]) && is_string($callable[0])) {
$callable = [$this->resolve($callable[0]), $callable[1]];
}
if (is_string($callable)) {
$callable = $this->resolve($callable);
}
return $callable(...$args);
}
public function getTarget(): Target
{
return $this->target;
}
public function getAttribute(): string
{
return $this->attribute;
}
public function getData(): array
{
return $this->data;
}
private function resolve(string $class)
{
if (Container::has($class)) {
return Container::get($class);
}
if (class_exists($class)) {
return new $class();
}
return $class;
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Libs\Attributes\Scanner;
enum Target
{
case IS_CLASS;
case IS_METHOD;
}

View File

@@ -0,0 +1,722 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
declare(strict_types=1);
namespace App\Libs\Database;
use App\Libs\Exceptions\DatabaseException as DBException;
use Closure;
use PDO;
use PDOException;
use PDOStatement;
use RuntimeException;
final class DBLayer
{
private const int LOCK_RETRY = 4;
private int $count = 0;
private string $driver;
private array $last = [
'sql' => '',
'bind' => [],
];
public const string WRITE_MODE = 'inWriteMode';
public const string IS_EQUAL = '=';
public const string IS_LIKE = 'LIKE';
public const string IS_IN = 'IN';
public const string IS_NULL = 'IS NULL';
public const string IS_HIGHER_THAN = '>';
public const string IS_HIGHER_THAN_OR_EQUAL = '>=';
public const string IS_LOWER_THAN = '<';
public const string IS_LOWER_THAN_OR_EQUAL = '<=';
public const string IS_NOT_EQUAL = '!=';
public const string IS_NOT_LIKE = 'NOT LIKE';
public const string IS_NOT_NULL = 'IS NOT NULL';
public const string IS_NOT_IN = 'NOT IN';
public const string IS_BETWEEN = 'BETWEEN';
public const string IS_NOT_BETWEEN = 'NOT BETWEEN';
public const string IS_LEFT_JOIN = 'LEFT JOIN';
public const string IS_INNER_JOIN = 'INNER JOIN';
public const string IS_LEFT_OUTER_JOIN = 'LEFT OUTER JOIN';
public const string IS_MATCH_AGAINST = 'MATCH() AGAINST()';
public const string IS_JSON_CONTAINS = 'JSON_CONTAINS';
public const string IS_JSON_SEARCH = 'JSON_SEARCH';
public function __construct(private PDO $pdo)
{
$driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if (is_string($driver)) {
$this->driver = $driver;
}
}
public function exec(string $sql, array $options = []): int|false
{
try {
$queryString = $sql;
$this->last = [
'sql' => $queryString,
'bind' => [],
];
$stmt = $this->pdo->exec($queryString);
} catch (PDOException $e) {
throw (new DBException($e->getMessage()))
->setInfo($queryString, [], $e->errorInfo ?? [], $e->getCode())
->setFile($e->getTrace()[$options['tracer'] ?? 1]['file'] ?? $e->getFile())
->setLine($e->getTrace()[$options['tracer'] ?? 1]['line'] ?? $e->getLine())
->setOptions([]);
}
return $stmt;
}
public function query(string $queryString, array $bind = [], array $options = []): PDOStatement
{
try {
$this->last = [
'sql' => $queryString,
'bind' => $bind,
];
$stmt = $this->pdo->prepare($queryString);
if (!($stmt instanceof PDOStatement)) {
throw new PDOException('Unable to prepare statement.');
}
$stmt->execute($bind);
if (false !== stripos($queryString, 'SQL_CALC_FOUND_ROWS')) {
if (false !== ($countStatement = $this->pdo->query('SELECT FOUND_ROWS();'))) {
$this->count = (int)$countStatement->fetch(PDO::FETCH_COLUMN);
}
}
} catch (PDOException $e) {
throw (new DBException($e->getMessage()))
->setInfo($queryString, $bind, $e->errorInfo ?? [], $e->getCode())
->setFile($e->getTrace()[$options['tracer'] ?? 1]['file'] ?? $e->getFile())
->setLine($e->getTrace()[$options['tracer'] ?? 1]['line'] ?? $e->getLine())
->setOptions($options);
}
return $stmt;
}
public function start(): bool
{
if ($this->pdo->inTransaction()) {
return false;
}
return $this->pdo->beginTransaction();
}
public function commit(): bool
{
return $this->pdo->commit();
}
public function rollBack(): bool
{
return $this->pdo->rollBack();
}
public function inTransaction(): bool
{
return $this->pdo->inTransaction();
}
public function delete(string $table, array $conditions, array $options = []): PDOStatement
{
if (empty($conditions)) {
throw new RuntimeException('Conditions Parameter is empty.');
}
$query = [];
$cond = $this->conditionParser($conditions);
$bind = $cond['bind'];
$query[] = 'DELETE FROM ' . $this->escapeIdentifier($table, true) . ' WHERE';
$query[] = implode(' AND ', $cond['query']);
if (array_key_exists('limit', $options)) {
$_ = $this->limitExpr($options['limit']);
$query[] = $_['query'];
$bind = array_replace_recursive($bind, $_['bind']);
}
$query = array_map('trim', $query);
if (!array_key_exists('tracer', $options)) {
$options['tracer'] = 2;
}
return $this->query(implode(' ', $query), $bind, $options);
}
/**
* Select Statement.
*
* @param string $table
* @param array<int,string> $cols
* @param array<string,array|string|int|mixed> $conditions
* @param array<string,mixed> $options
*
* @return PDOStatement
*/
public function select(string $table, array $cols = [], array $conditions = [], array $options = []): PDOStatement
{
$bind = [];
$col = '*';
if (count($cols) >= 1) {
$cols = array_map(
function ($text) {
if ('*' === $text) {
return $text;
}
return $this->escapeIdentifier($text, true);
},
$cols
);
$col = implode(', ', $cols);
}
if (array_key_exists('count', $options) && $options['count']) {
$this->getCount($table, $conditions, $options);
}
$query = [];
$query[] = "SELECT {$col} FROM " . $this->escapeIdentifier($table, true);
if (!empty($conditions)) {
$andOr = $options['andor'] ?? 'AND';
$cond = $this->conditionParser($conditions);
if (!empty($cond['query'])) {
$query[] = 'WHERE ' . implode(" {$andOr} ", $cond['query']);
}
$bind = array_replace_recursive($bind, $cond['bind']);
}
if (array_key_exists('groupby', $options) && is_array($options['groupby'])) {
$query[] = $this->groupByExpr($options['groupby'])['query'];
}
if (array_key_exists('orderby', $options) && is_array($options['orderby'])) {
$query[] = $this->orderByExpr($options['orderby'])['query'];
}
if (array_key_exists('limit', $options)) {
$_ = $this->limitExpr((int)$options['limit'], $options['start'] ?? null);
$query[] = $_['query'];
$bind = array_replace_recursive($bind, $_['bind']);
}
$query = array_map('trim', $query);
if (!array_key_exists('tracer', $options)) {
$options['tracer'] = 2;
}
return $this->query(implode(' ', $query), $bind, $options);
}
public function getCount(string $table, array $conditions = [], array $options = []): void
{
$bind = $query = [];
$query[] = "SELECT COUNT(*) FROM " . $this->escapeIdentifier($table, true);
if (!empty($conditions)) {
$cond = $this->conditionParser($conditions);
if (!empty($cond['query'])) {
$query[] = 'WHERE ' . implode(' AND ', $cond['query']);
}
$bind = $cond['bind'];
}
if (array_key_exists('groupby', $options) && is_array($options['groupby'])) {
$query[] = $this->groupByExpr($options['groupby'])['query'];
}
if (array_key_exists('orderby', $options) && is_array($options['orderby'])) {
$query[] = $this->orderByExpr($options['orderby'])['query'];
}
$query = array_map('trim', $query);
if (!array_key_exists('tracer', $options)) {
$options['tracer'] = 1;
}
$this->count = (int)$this->query(implode(' ', $query), $bind, $options)->fetchColumn();
}
public function update(string $table, array $changes, array $conditions, array $options = []): PDOStatement
{
if (empty($changes)) {
throw new RuntimeException('Changes Parameter is empty.');
}
if (empty($conditions)) {
throw new RuntimeException('Conditions Parameter is empty.');
}
$bind = $query = $updated = [];
$query[] = 'UPDATE ' . $this->escapeIdentifier($table, true) . ' SET';
foreach ($changes as $columnName => $columnValue) {
$bindKey = '__dbu_' . $columnName;
$bind[$bindKey] = $columnValue;
$updated[] = sprintf(
'%s = :%s',
$this->escapeIdentifier($columnName, true),
$this->escapeIdentifier($bindKey)
);
}
$query[] = implode(', ', $updated);
$cond = $this->conditionParser($conditions);
$bind = array_replace_recursive($bind, $cond['bind']);
$query[] = 'WHERE ' . implode(' AND ', $cond['query']);
if (array_key_exists('limit', $options)) {
$_ = $this->limitExpr((int)$options['limit']);
$query[] = $_['query'];
$bind = array_replace_recursive($bind, $_['bind']);
}
$query = array_map('trim', $query);
if (!array_key_exists('tracer', $options)) {
$options['tracer'] = 1;
}
return $this->query(implode(' ', $query), $bind, $options);
}
public function insert(string $table, array $conditions, array $options = []): PDOStatement
{
if (empty($conditions)) {
throw new RuntimeException('Conditions Parameter is empty, Expecting associative array.');
}
$queryString = 'INSERT INTO ' . $this->escapeIdentifier($table, true) . ' ((columns)) VALUES((values))';
$columns = $placeholder = [];
foreach (array_keys($conditions) as $v) {
$columns[] = $this->escapeIdentifier($v, true);
$placeholder[] = sprintf(':%s', $this->escapeIdentifier($v, false));
}
$queryString = str_replace(
['(columns)', '(values)'],
[implode(', ', $columns), implode(', ', $placeholder)],
$queryString
);
$queryString = trim($queryString);
if (!array_key_exists('tracer', $options)) {
$options['tracer'] = 1;
}
return $this->query($queryString, $conditions, $options);
}
public function quote(mixed $text, int $type = PDO::PARAM_STR): string
{
return (string)$this->pdo->quote($text, $type);
}
public function escape(string $text): string
{
return mb_substr($this->quote($text), 1, -1, 'UTF-8');
}
public function id(string|null $name = null): string
{
return false !== ($id = $this->pdo->lastInsertId($name)) ? $id : '';
}
public function totalRows(): int
{
return $this->count;
}
public function close(): bool
{
return true;
}
/**
* Make sure only valid characters make it in column/table names
*
* @see https://stackoverflow.com/questions/10573922/what-does-the-sql-standard-say-about-usage-of-backtick
*
* @param string $text table or column name
* @param bool $quote certain SQLs escape column names (i.e. mysql with `backticks`)
*
* @return string
*/
public function escapeIdentifier(string $text, bool $quote = false): string
{
// table or column has to be valid ASCII name.
// this is opinionated, but we only allow [a-zA-Z0-9_] in column/table name.
if (!preg_match('#\w#', $text)) {
throw new RuntimeException(
sprintf(
'Invalid identifier "%s": Column/table must be valid ASCII code.',
$text
)
);
}
// The first character cannot be [0-9]:
if (preg_match('/^\d/', $text)) {
throw new RuntimeException(
sprintf(
'Invalid identifier "%s": Must begin with a letter or underscore.',
$text
)
);
}
if ($quote) {
return match ($this->driver) {
'mssql' => '[' . $text . ']',
'mysql', 'mariadb' => '`' . $text . '`',
default => '"' . $text . '"',
};
}
return $text;
}
public function getDriver(): string
{
return $this->driver;
}
private function conditionParser(array $conditions): array
{
$keys = $bind = [];
foreach ($conditions as $column => $opt) {
$column = trim($column);
/** @noinspection PhpUnusedLocalVariableInspection */
$eBindName = '__dbw_' . $this->escapeIdentifier($column);
$eColumnName = $this->escapeIdentifier($column, true);
if (!is_array($opt)) {
$opt = [self::IS_EQUAL, $opt];
}
switch ($opt[0]) {
case self::IS_EQUAL:
case self::IS_NOT_EQUAL:
case self::IS_HIGHER_THAN:
case self::IS_HIGHER_THAN_OR_EQUAL:
case self::IS_LOWER_THAN:
case self::IS_LOWER_THAN_OR_EQUAL:
$eBindName = '__db_cOp_' . random_int(1, 10000);
$keys[] = str_replace(
['(column)', '(bind)', '(expr)'],
[
$eColumnName,
$eBindName,
(function ($expr): string {
return match ($expr) {
self::IS_EQUAL => self::IS_EQUAL,
self::IS_NOT_EQUAL => self::IS_NOT_EQUAL,
self::IS_HIGHER_THAN => self::IS_HIGHER_THAN,
self::IS_HIGHER_THAN_OR_EQUAL => self::IS_HIGHER_THAN_OR_EQUAL,
self::IS_LOWER_THAN => self::IS_LOWER_THAN,
self::IS_LOWER_THAN_OR_EQUAL => self::IS_LOWER_THAN_OR_EQUAL,
default => throw new RuntimeException(sprintf('SQL (%s) not implemented.', $expr)),
};
})(
$opt[0]
)
],
'(column) (expr) :(bind)'
);
$bind[$eBindName] = $opt[1];
break;
case self::IS_BETWEEN:
case self::IS_NOT_BETWEEN:
$eBindName1 = ':__db_b1_' . random_int(1, 1000);
$eBindName2 = ':__db_b2_' . random_int(1, 1000);
$keys[] = str_replace(
['(column)', '(bind1)', '(bind2)', '(expr)'],
[
$eColumnName,
$eBindName1,
$eBindName2,
(function ($expr): string {
return match ($expr) {
self::IS_BETWEEN => self::IS_BETWEEN,
self::IS_NOT_BETWEEN => self::IS_NOT_BETWEEN,
default => throw new RuntimeException(sprintf('SQL (%s) not implemented.', $expr)),
};
})(
$opt[0]
)
],
"(column) (expr) (bind1) AND (bind2)"
);
$bind[$eBindName1] = $opt[1][0];
$bind[$eBindName2] = $opt[1][1];
break;
case self::IS_NULL:
case self::IS_NOT_NULL:
$keys[] = str_replace(
['(column)'],
[$eColumnName],
(function ($expr): string {
return (self::IS_NULL === $expr) ? "(column) IS NULL" : "(column) IS NOT NULL";
})(
$opt[0]
)
);
break;
case self::IS_LIKE:
case self::IS_NOT_LIKE:
$eBindName = '__db_lk_' . random_int(1, 1000);
$keys[] = str_replace(
['(column)', '(bind)', '(expr)'],
[
$eColumnName,
$eBindName,
(function ($expr): string {
return match ($expr) {
self::IS_LIKE => self::IS_LIKE,
self::IS_NOT_LIKE => self::IS_NOT_LIKE,
default => throw new RuntimeException(sprintf('SQL (%s) not implemented.', $expr)),
};
})(
$opt[0]
)
],
(function ($driver) {
if ('sqlite' === $driver) {
return "(column) (expr) '%' || :(bind) || '%'";
}
return "(column) (expr) CONCAT('%',:(bind),'%')";
})(
$this->driver
)
);
$bind[$eBindName] = $opt[1];
break;
case self::IS_IN:
case self::IS_NOT_IN:
$inExpr = $this->inExpr($column, $opt[1]);
$keys[] = str_replace(
['(column)', '(bind)', '(expr)'],
[
$eColumnName,
$inExpr['query'],
(function ($expr): string {
return match ($expr) {
self::IS_IN => self::IS_IN,
self::IS_NOT_IN => self::IS_NOT_IN,
default => throw new RuntimeException(sprintf('SQL (%s) not implemented.', $expr)),
};
})(
$opt[0]
)
],
"(column) (expr) ((bind))"
);
$bind = array_replace_recursive($bind, $inExpr['bind'] ?? []);
break;
case self::IS_MATCH_AGAINST:
if (!isset($opt[1], $opt[2])) {
throw new RuntimeException('IS_MATCH_AGAINST: expects 2 parameters.');
}
if (!is_array($opt[1])) {
throw new RuntimeException(
sprintf('IS_MATCH_AGAINST: expects parameter 1 to be array. %s given.', gettype($opt[1]))
);
}
if (!is_string($opt[2])) {
throw new RuntimeException(
sprintf('IS_MATCH_AGAINST: expects parameter 2 to be string. %s given', gettype($opt[2]))
);
}
$eBindName = '__db_ftS_' . random_int(1, 1000);
$keys[] = sprintf(
"MATCH(%s) AGAINST(%s)",
implode(', ', array_map(fn($columns) => $this->escapeIdentifier($columns, true), $opt[1])),
':' . $eBindName
);
$bind[$eBindName] = $opt[2];
break;
case self::IS_JSON_CONTAINS:
if (!isset($opt[1], $opt[2])) {
throw new RuntimeException('IS_JSON_CONTAINS: expects 2 parameters.');
}
$eBindName = '__db_jc_' . random_int(1, 1000);
$keys[] = sprintf(
"JSON_CONTAINS(%s, %s) > %d",
$this->escapeIdentifier($opt[1], true),
':' . $eBindName,
(int)($opt[3] ?? 0)
);
$bind[$eBindName] = $opt[2];
break;
case self::IS_INNER_JOIN:
case self::IS_LEFT_JOIN:
case self::IS_LEFT_OUTER_JOIN:
default:
throw new RuntimeException(sprintf('SQL (%s) expr not implemented.', $opt[0]));
}
}
return [
'bind' => $bind,
'query' => $keys,
];
}
private function inExpr(string $key, array $parameters): array
{
$i = 0;
$token = "__in_{$key}_";
$bind = [];
foreach ($parameters as $param) {
$i++;
$bind[$token . $i] = $param;
}
return [
'bind' => $bind,
'query' => ':' . implode(', :', array_keys($bind))
];
}
private function groupByExpr(array $groupBy): array
{
$groupBy = array_map(
fn($val) => $this->escapeIdentifier($val, true),
$groupBy
);
return ['query' => 'GROUP BY ' . implode(', ', $groupBy)];
}
private function orderByExpr(array $orderBy): array
{
$sortBy = [];
foreach ($orderBy as $columnName => $columnSort) {
$columnSort = ('DESC' === strtoupper($columnSort)) ? 'DESC' : 'ASC';
$sortBy[] = $this->escapeIdentifier($columnName, true) . ' ' . $columnSort;
}
return ['query' => 'ORDER BY ' . implode(', ', $sortBy)];
}
private function limitExpr(int $limit, ?int $start = null): array
{
$bind = [
'__db_limit' => $limit,
];
if (is_int($start)) {
$query = 'LIMIT :__db_start, :__db_limit';
$bind['__db_start'] = $start;
} else {
$query = 'LIMIT :__db_limit';
}
return [
'bind' => $bind,
'query' => $query,
];
}
public function getLastStatement(): array
{
return $this->last;
}
public function transactional(Closure $callback): mixed
{
$autoStartTransaction = false === $this->inTransaction();
for ($i = 1; $i <= self::LOCK_RETRY; $i++) {
try {
if (!$autoStartTransaction) {
$this->start();
}
$result = $callback($this);
if (!$autoStartTransaction) {
$this->commit();
}
$this->last = $this->getLastStatement();
return $result;
} catch (DBException $e) {
if (!$autoStartTransaction && $this->inTransaction()) {
$this->rollBack();
}
//-- sometimes sqlite is locked, therefore attempt to sleep until it's unlocked.
if (false !== stripos($e->getMessage(), 'database is locked')) {
// throw exception if happens self::LOCK_RETRY times in a row.
if ($i >= self::LOCK_RETRY) {
throw $e;
}
/** @noinspection PhpUnhandledExceptionInspection */
sleep(self::LOCK_RETRY + random_int(1, 3));
} else {
throw $e;
}
}
}
/**
* We return in try or throw exception.
* As such this return should never be reached.
*/
return null;
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Libs\Events;
use App\Model\Events\Event as EventInfo;
use Symfony\Contracts\EventDispatcher\Event;
class DataEvent extends Event
{
public function __construct(private readonly EventInfo $eventInfo)
{
}
public function getEvent(): EventInfo
{
return $this->eventInfo;
}
public function addLog(string $log): void
{
$this->eventInfo->logs[] = $log;
}
public function getLogs(): array
{
return $this->eventInfo->logs;
}
public function getData(): array
{
return $this->eventInfo->event_data;
}
}

View File

@@ -14,4 +14,62 @@ use RuntimeException;
*/
class DatabaseException extends RuntimeException
{
public string $queryString = '';
public array $bind = [];
public array $options = [];
public array $errorInfo = [];
/**
* @param string $queryString
* @param array $bind
* @param array $errorInfo
* @param string|int $errorCode
*
* @return $this
*/
public function setInfo(
string $queryString,
array $bind = [],
array $errorInfo = [],
mixed $errorCode = 0
): self {
$this->queryString = $queryString;
$this->bind = $bind;
$this->errorInfo = $errorInfo;
$this->code = $errorCode;
return $this;
}
public function getQueryString(): string
{
return $this->queryString;
}
public function getQueryBind(): array
{
return $this->bind;
}
public function setFile(string $file): DatabaseException
{
$this->file = $file;
return $this;
}
public function setLine(int $line): DatabaseException
{
$this->line = $line;
return $this;
}
public function setOptions(array $options): DatabaseException
{
$this->options = $options;
return $this;
}
}

View File

@@ -25,7 +25,7 @@ use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7Server\ServerRequestCreator;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Psr\Log\LoggerInterface;
use Psr\Log\LoggerInterface as iLogger;
use Psr\SimpleCache\CacheInterface;
use Symfony\Component\Console\CommandLoader\ContainerCommandLoader;
use Symfony\Component\Yaml\Yaml;
@@ -42,7 +42,8 @@ final class Initializer
{
private Cli $cli;
private ConsoleOutput $cliOutput;
private LoggerInterface|null $accessLog = null;
private iLogger|null $accessLog = null;
private bool $booted = false;
/**
* Initializes the object.
@@ -76,7 +77,6 @@ final class Initializer
Container::add($name, $definition);
}
// -- Add the Initializer class to the container.
Container::add(self::class, ['shared' => true, 'class' => $this]);
$this->cliOutput = new ConsoleOutput();
@@ -94,6 +94,12 @@ final class Initializer
*/
public function boot(): self
{
static $booted = false;
if (true === $booted) {
return $this;
}
$this->createDirectories();
(function () {
@@ -123,7 +129,7 @@ final class Initializer
date_default_timezone_set(Config::get('tz', 'UTC'));
$logger = Container::get(LoggerInterface::class);
$logger = Container::get(iLogger::class);
$this->setupLoggers($logger, Config::get('logger'));
@@ -160,6 +166,9 @@ final class Initializer
]);
});
registerEvents();
$booted = true;
return $this;
}
@@ -230,7 +239,7 @@ final class Initializer
);
if (Status::SERVICE_UNAVAILABLE->value === $statusCode) {
Container::get(LoggerInterface::class)->error($e->getMessage(), [
Container::get(iLogger::class)->error($e->getMessage(), [
'kind' => $e::class,
'file' => $e->getFile(),
'line' => $e->getLine(),
@@ -258,7 +267,7 @@ final class Initializer
]
);
Container::get(LoggerInterface::class)->error($e->getMessage(), [
Container::get(iLogger::class)->error($e->getMessage(), [
'kind' => $e::class,
'file' => $e->getFile(),
'line' => $e->getLine(),

View File

@@ -740,7 +740,7 @@ final class DirectMapper implements iImport
*/
public function commit(): array
{
if (count($this->progressItems) >= 1) {
if (true === (bool)env('WS_CRON_PROGRESS', false) && count($this->progressItems) >= 1) {
try {
$progress = $this->cache->get('progress', []);
foreach ($this->progressItems as $itemId => $entity) {

View File

@@ -543,7 +543,7 @@ final class MemoryMapper implements iImport
public function commit(): mixed
{
if (true !== $this->inDryRunMode()) {
if (count($this->progressItems) >= 1) {
if (true === (bool)env('WS_CRON_PROGRESS', false) && count($this->progressItems) >= 1) {
try {
$progress = $this->cache->get('progress', []);
foreach ($this->progressItems as $itemId => $entity) {

View File

@@ -7,14 +7,18 @@ use App\Backends\Common\Cache as BackendCache;
use App\Backends\Common\ClientInterface as iClient;
use App\Backends\Common\Context;
use App\Libs\APIResponse;
use App\Libs\Attributes\Scanner\Attributes as AttributesScanner;
use App\Libs\Attributes\Scanner\Item as ScannerItem;
use App\Libs\Config;
use App\Libs\Container;
use App\Libs\DataUtil;
use App\Libs\Entity\StateInterface as iState;
use App\Libs\Enums\Http\Status;
use App\Libs\Events\DataEvent;
use App\Libs\Exceptions\InvalidArgumentException;
use App\Libs\Exceptions\RuntimeException;
use App\Libs\Extends\Date;
use App\Libs\Extends\ReflectionContainer;
use App\Libs\Guid;
use App\Libs\Initializer;
use App\Libs\Options;
@@ -22,15 +26,22 @@ use App\Libs\Response;
use App\Libs\Router;
use App\Libs\Stream;
use App\Libs\Uri;
use App\Model\Events\Event as EventInfo;
use App\Model\Events\EventListener;
use App\Model\Events\EventsRepository;
use App\Model\Events\EventStatus;
use Monolog\Utils;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7Server\ServerRequestCreator;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Psr\Http\Message\StreamInterface as iStream;
use Psr\Http\Message\UriInterface as iUri;
use Psr\Log\LoggerInterface as iLogger;
use Psr\SimpleCache\CacheInterface;
use Psr\SimpleCache\CacheInterface as iCache;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Process\Process;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
@@ -713,14 +724,19 @@ if (!function_exists('getAppVersion')) {
$version = Config::get('version', 'dev-master');
if ('$(version_via_ci)' === $version) {
$gitDir = ROOT_PATH . '/.git/';
$gitDir = ROOT_PATH . DIRECTORY_SEPARATOR . '.git' . DIRECTORY_SEPARATOR;
if (is_dir($gitDir)) {
$cmd = 'git --git-dir=%1$s describe --exact-match --tags 2> /dev/null || git --git-dir=%1$s rev-parse --short HEAD';
exec(sprintf($cmd, escapeshellarg($gitDir)), $output, $status);
if (0 === $status) {
return $output[0] ?? 'dev-master';
$cmdVersion = [
'git --git-dir=%1$s describe --exact-match --tags',
'git --git-dir=%1$s rev-parse --short HEAD',
];
foreach ($cmdVersion as $cmd) {
$proc = Process::fromShellCommandline(sprintf($cmd, escapeshellarg($gitDir)));
$proc->run();
if ($proc->isSuccessful()) {
return explode(PHP_EOL, $proc->getOutput())[0];
}
}
}
@@ -1687,7 +1703,7 @@ if (!function_exists('restartTaskWorker')) {
}
if (file_exists(r('/proc/{pid}/status', ['pid' => $pid]))) {
@posix_kill((int)$pid, $force ? SIGKILL : SIGHUP);
@posix_kill((int)$pid, $force ? 9 : 1);
}
clearstatcache(true, $pidFile);
@@ -1837,3 +1853,124 @@ if (!function_exists('ffprobe_file')) {
return $data;
}
}
if (!function_exists('generateUUID')) {
function generateUUID(string|int|null $prefix = null): string
{
$prefixUUID = '';
if (null !== $prefix) {
$prefixUUID = $prefix ? $prefix . '-' : '';
}
return $prefixUUID . Ramsey\Uuid\Uuid::uuid6()->toString();
}
}
if (!function_exists('cacheableItem')) {
/**
* Get Item From Cache or call Callable and cache result.
*
* @param string $key
* @param Closure $function
* @param DateInterval|int|null $ttl
* @param bool $ignoreCache
*
* @return mixed
*/
function cacheableItem(
string $key,
Closure $function,
DateInterval|int|null $ttl = null,
bool $ignoreCache = false
): mixed {
$cache = Container::get(CacheInterface::class);
if (!$ignoreCache && $cache->has($key)) {
return $cache->get($key);
}
$reflectContainer = Container::get(ReflectionContainer::class);
$item = $reflectContainer->call($function);
if (null === $ttl) {
$ttl = new DateInterval('PT300S');
}
$cache->set($key, $item, $ttl);
return $item;
}
}
if (!function_exists('registerEvents')) {
/**
* Register events.
*/
function registerEvents(bool $ignoreCache = false): void
{
static $alreadyRegistered = false;
if (false !== $alreadyRegistered) {
return;
}
$logger = Container::get(iLogger::class);
$dispatcher = Container::get(EventDispatcherInterface::class);
assert($dispatcher instanceof EventDispatcher);
/** @var array<ScannerItem> $list */
$list = cacheableItem(
'event_listeners',
fn() => AttributesScanner::scan(Config::get('events.listeners.locations', []))->for(EventListener::class),
Config::get('events.listeners.cache', fn() => new DateInterval('PT1H')),
$ignoreCache
);
foreach ($list as $item) {
$dispatcher->addListener(ag($item->getData(), 'event'), $item->call(...));
}
if (null !== ($eventsFile = Config::get('events.listeners.file'))) {
try {
foreach (require $eventsFile as $event) {
$dispatcher->addListener(ag($event, 'on'), ag($event, 'callable'));
}
} catch (Throwable $e) {
$logger->error($e->getMessage(), []);
}
}
$alreadyRegistered = true;
}
}
if (!function_exists('queueEvent')) {
/**
* Queue Event.
*
* @param string $event Event name.
* @param array $data Event data.
* @param array $opts Options.
*
* @return EventInfo
*/
function queueEvent(string $event, array $data = [], array $opts = []): EventInfo
{
$repo = ag($opts, EventsRepository::class, fn() => Container::get(EventsRepository::class));
assert($repo instanceof EventsRepository);
$item = $repo->getObject([]);
$item->event = $event;
$item->status = EventStatus::PENDING;
$item->event_data = $data;
$item->created_at = makeDate();
$item->options = [
'class' => ag($opts, 'class', DataEvent::class),
];
$id = $repo->save($item);
$item->id = $id;
return $item;
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\libs\Events\DataEvent;
use App\Model\Events\EventListener;
#[EventListener(self::NAME)]
final readonly class OnTestEvent
{
public const string NAME = 'test_event';
public function __invoke(DataEvent $e): DataEvent
{
$e->stopPropagation();
return $e;
}
}

View File

@@ -0,0 +1,343 @@
<?php
declare(strict_types=1);
namespace App\Model\Base;
use App\Libs\Container;
use App\Libs\Extends\Date;
use App\Model\Base\Enums\TransformType;
use InvalidArgumentException;
use JsonSerializable;
use ReflectionNamedType;
use ReflectionObject;
use ReflectionProperty;
use ReflectionUnionType;
use Stringable;
abstract class BasicModel implements jsonSerializable
{
/**
* @var array<string,mixed> Copy of the original data.
*/
protected array $data = [];
/**
* @var array<string, class-string|callable(TransformType $type, mixed $data):mixed> Transformations for properties.
*/
protected array $transform = [];
/**
* Custom compare for complex data types. If the callable returns true,
* the value is considered unchanged. Otherwise, it's considered changed.
*
* @var array<string, callable(mixed $old, mixed $new):bool>
*/
protected array $differ = [];
/**
* @var array<string,string> Casts for properties.
*/
protected array $casts = [];
/**
* @var array<class-string,array<string,array>> Properties for the class.
*/
private static array $_props = [];
/**
* @var array<class-string,array<string,string>> Columns for the class.
*/
private static array $_columns = [];
/**
* @var bool Loaded from DB.
*/
protected bool $fromDB = false;
/**
* @var bool Whether the queried data is Custom.
*/
protected bool $isCustom = false;
/**
* @var string Refers to table Unique ID.
*/
protected string $primaryKey = 'id';
/**
* Receive Data as key/value pairs.
*
* @param array $data
* @param bool $isCustom If True Object **SHOULD NOT** pass checks.
* @param array $options
*/
public function __construct(array $data = [], bool $isCustom = false, array $options = [])
{
$this->init($data, $isCustom, $options);
$this->isCustom = $isCustom;
if (array_key_exists('fromDB', $options)) {
$this->fromDB = (bool)$options['fromDB'];
}
if (array_key_exists('primaryKey', $options)) {
$this->primaryKey = (string)$options['primaryKey'];
}
$data = $this->transform(TransformType::DECODE, $data);
foreach ($data as $key => $value) {
$value = $this->setValueType($key, $value);
$this->{$key} = $value;
$this->data[$key] = $value;
}
}
protected function init(array &$data, bool &$isCustom, array &$options): void
{
}
abstract public function validate(): bool;
public function isFromDB(?bool $fromDB = null): bool
{
if (null !== $fromDB) {
$this->fromDB = $fromDB;
}
return $this->fromDB;
}
public function apply(BasicModel $model): static
{
foreach ($model->getAll() as $key => $value) {
if ($key === $this->primaryKey) {
continue;
}
$this->{$key} = $this->setValueType($key, $value);
}
return $this;
}
public function hasPrimaryKey(): bool
{
return property_exists($this, 'primaryKey') && !empty($this->{$this->primaryKey});
}
public function getAll(bool $transform = false): array
{
$props = [];
$reflect = (new ReflectionObject($this))->getProperties(ReflectionProperty::IS_PUBLIC);
foreach ($reflect as $src) {
$value = $src->getValue($this);
if ($value instanceof Stringable) {
$value = (string)$value;
}
$props[$src->getName()] = $value;
}
return true === $transform ? $this->transform(TransformType::ENCODE, $props) : $props;
}
public function diff(bool $deep = false, bool $transform = false): array
{
$changed = [];
foreach ($this->getAll() as $key => $value) {
if (false === array_key_exists($key, $this->data)) {
continue;
}
$old = $this->data[$key];
// -- custom compare in case of complex data types.
if (null !== ($fn = $this->differ[$key] ?? null) && true === (bool)$fn($old, $value)) {
continue;
} elseif ($value === $old) {
continue;
}
$changed[$key] = false === $deep ? $value : [
'old' => $old ?? null,
'new' => $value,
];
}
if (true === $transform && !empty($changed)) {
foreach ($changed as $key => $value) {
if (false === $deep) {
$changed[$key] = $this->transform(TransformType::ENCODE, [$key => $value])[$key];
} else {
$changed[$key] = [
'old' => $this->transform(TransformType::ENCODE, [$key => $value['old']])[$key],
'new' => $this->transform(TransformType::ENCODE, [$key => $value['new']])[$key],
];
}
}
}
return $changed;
}
/**
* Get Schema type for data Validation.
*
* This relies on Entity being strongly typed.
*
* @return array<string,array<string>>
*/
public function getSchemaDataType(): array
{
$className = get_class($this);
if (isset(self::$_props[$className])) {
return self::$_props[$className];
}
self::$_props[$className] = [];
$reflect = (new ReflectionObject($this))->getProperties(ReflectionProperty::IS_PUBLIC);
foreach ($reflect as $src) {
$prop = $src->getType();
$propName = $src->getName();
if (null === $prop) {
self::$_props[$className][$propName] = ['mixed'];
continue;
}
if ($prop instanceof ReflectionNamedType) {
self::$_props[$className][$propName][] = $prop->getName();
if ($prop->allowsNull()) {
self::$_props[$className][$propName][] = 'null';
}
continue;
}
if ($prop instanceof ReflectionUnionType) {
foreach ($prop->getTypes() as $typed) {
self::$_props[$className][$propName][] = $typed->getName();
}
}
}
return self::$_props[$className];
}
public function setValueType(string $key, mixed $value): mixed
{
if (!isset($this->casts[$key])) {
return $value;
}
if ('int' === $this->casts[$key] && ($value instanceof Date)) {
$value = $value->getTimestamp();
}
if (get_debug_type($value) === $this->casts[$key]) {
return $value;
}
settype($value, $this->casts[$key]);
return $value;
}
public function getColumnsNames(): array
{
$className = get_class($this);
if (isset(self::$_columns[$className])) {
return self::$_columns[$className];
}
self::$_columns[$className] = [];
foreach ((new ReflectionObject($this))->getConstants() as $key => $val) {
if (!str_starts_with($key, 'COLUMN_')) {
continue;
}
self::$_columns[$className][$key] = $val;
}
return self::$_columns[$className];
}
public function getPrimaryData(): array
{
return $this->data;
}
public function updatePrimaryData(): self
{
$this->data = $this->getAll();
return $this;
}
public function getPrimaryId(): mixed
{
return $this->data[$this->primaryKey] ?? $this->{$this->primaryKey} ?? null;
}
public function getPrimaryKey(): string
{
return $this->primaryKey;
}
public function isCustom(): bool
{
return $this->isCustom;
}
public function __debugInfo(): array
{
return $this->getAll();
}
public function __destruct()
{
self::$_props = self::$_columns = [];
}
public function jsonSerialize(): array
{
return $this->getAll();
}
protected function transform(TransformType $type, array $data): array
{
if (empty($this->transform)) {
return $data;
}
foreach ($this->transform as $key => $callable) {
if (false === array_key_exists($key, $data)) {
continue;
}
if (false === is_callable($callable)) {
if (true === is_string($callable) && true === class_exists($callable)) {
$callable = Container::get($callable);
} else {
throw new InvalidArgumentException(sprintf("Transformer for '%s', is not callable.", $key));
}
}
$data[$key] = $callable($type, $data[$key]);
}
return $data;
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Model\Base;
use App\Libs\Extends\Date;
use App\Model\Base\Exceptions\ValidationException;
use App\Model\Base\Exceptions\VValidateException;
use InvalidArgumentException;
use RuntimeException;
use Stringable;
abstract class BasicValidation
{
/**
* @var bool Whether the checks are successful.
*/
protected bool $isValid = false;
/**
* @var array<string,array<string>> Declared property datatype. Example [ 'id' => [ 'int', 'null' ] ]
*/
protected array $schemeDataType = [];
/**
* @var array<string,array<callable(mixed $value):bool>> Run custom validator on property. MUST return bool(true) to pass.
*/
protected array $schemeValidate = [];
/**
* @var array<string,array<callable(mixed $value):mixed>> Run custom filter on property.
*/
protected array $schemeFilter = [];
protected function runValidator(BasicModel $model): void
{
$this->schemeDataType = array_replace_recursive($model->getSchemaDataType(), $this->schemeDataType);
foreach ($model->getAll() as $fieldName => $fieldValue) {
if (!empty($this->schemeDataType)) {
if (!array_key_exists($fieldName, $this->schemeDataType)) {
throw new ValidationException(
sprintf("'%s' is not part of '%s' data properties.", $fieldName, get_class($model))
);
}
$this->checkDataTypes($fieldName, $fieldValue);
}
if (isset($this->schemeValidate[$fieldName])) {
foreach ($this->schemeValidate[$fieldName] as $_fn) {
if (true !== is_callable($_fn)) {
throw new RuntimeException(
sprintf("Validation Filter for '%s' is not a callable.", $fieldName)
);
}
if (true !== $_fn($fieldValue)) {
throw new VValidateException(
sprintf("Validation Filter for '%s' returned non-true.", $fieldName)
);
}
}
}
if (isset($this->schemeFilter[$fieldName])) {
foreach ($this->schemeFilter[$fieldName] as $_fn) {
if (true !== is_callable($_fn)) {
throw new RuntimeException(sprintf("Data Filter for '%s' is not callable.", $fieldName));
}
$model->{$fieldName} = $_fn($fieldValue);
}
}
}
$this->isValid = true;
}
protected function checkDataTypes(string $name, mixed $value): bool
{
if (!is_array($this->schemeDataType[$name])) {
throw new InvalidArgumentException(
sprintf(
"Invalid data type returned from schemeDataType. expecting array. got '%s' instead.",
gettype($value)
)
);
}
$passCheck = false;
foreach ($this->schemeDataType[$name] as $_type) {
if ($this->checkType($value, $_type)) {
$passCheck = true;
}
}
if (!$passCheck) {
throw new InvalidArgumentException(
sprintf(
"'%s' expects '%s' data type, but '%s' was given.",
$name,
implode(', ', $this->schemeDataType[$name]),
get_debug_type($value)
)
);
}
return true;
}
/**
* Whether Validation Checks out.
*
* @return bool
*/
public function isValid(): bool
{
return $this->isValid;
}
protected function checkType(mixed $value, string $type): bool
{
if ($type === gettype($value) || $type === get_debug_type($value)) {
return true;
}
return match ($type) {
'int', 'integer' => is_int($value),
'string' => is_string($value),
'bool', 'boolean' => is_bool($value),
'double' => is_double($value),
'float' => is_float($value),
'array' => is_array($value),
'null', 'NULL' => null === $value,
'object' => is_object($value),
'resource', 'resource (closed)' => is_resource($value),
Stringable::class => $value instanceof Stringable,
Date::class => $value instanceof Date,
'mixed' => true,
default => false
};
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Model\Base\Enums;
enum ScalarType
{
case STRING;
case INT;
case FLOAT;
case BOOL;
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Model\Base\Enums;
enum TransformType
{
case ENCODE;
case DECODE;
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Model\Base\Exceptions;
use RuntimeException;
/**
* MustBeNonEmpty Thrown when Value is empty.
*
* @package Model\Base\Exceptions
*/
class MustBeNonEmpty extends RuntimeException
{
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Model\Base\Exceptions;
/**
* VDateTypeException Thrown when DataType check fails.
*
* @package Model\Base\Exceptions
*/
class VDateTypeException extends ValidationException
{
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Model\Base\Exceptions;
/**
* VFilterException Thrown when filter fails.
*
* @package Model\Base\Exceptions
*/
class VFilterException extends ValidationException
{
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Model\Base\Exceptions;
/**
* VValidateException Thrown when Custom Validator fails.
*
* @package Model\Base\Exceptions
*/
class VValidateException extends ValidationException
{
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Model\Base\Exceptions;
use InvalidArgumentException;
/**
* All Validation Should Extend this class.
*/
class ValidationException extends InvalidArgumentException
{
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Model\Base\Interfaces;
use App\Model\Base\BasicModel;
interface IDInterface
{
/**
* Create a new ID.
*
* @param BasicModel $model The model to create an ID for.
*
* @return int|string
*/
public function makeId(BasicModel $model): int|string;
}

View File

@@ -0,0 +1,237 @@
<?php
declare(strict_types=1);
namespace App\Model\Base\Traits;
use App\Libs\Database\DBLayer;
use App\Model\Base\BasicModel;
use App\Model\Base\Interfaces\IDInterface;
use Generator;
use InvalidArgumentException;
use RuntimeException;
trait UsesBasicRepository
{
use UsesPaging;
protected DBLayer $db;
public function __construct(DBLayer $db)
{
$this->init($db);
$this->db = $db;
if (empty($this->table)) {
throw new RuntimeException('You must set table name in $this->table');
}
}
private function init(DBLayer $db): void
{
}
private function _findOne(array $criteria, array $cols = []): mixed
{
if (empty($criteria)) {
throw new InvalidArgumentException('criteria is empty.');
}
$q = $this->db->select($this->table, $cols, $criteria, ['limit' => 1])->fetch();
if (empty($q)) {
return null;
}
$isCustom = !empty($cols);
$item = $this->getObject($q, $isCustom);
return $isCustom || $item->validate() ? $item : null;
}
private function _findAll(array $criteria = [], array $cols = []): array
{
$arr = [];
$q = $this->db->select($this->table, $cols, $criteria, [
'count' => true,
'start' => $this->getStart(),
'limit' => $this->getPerpage(),
'orderby' => [$this->getSort() => $this->getOrder()],
]);
$isCustom = !empty($cols);
$this->setTotal($this->db->totalRows());
while ($row = $q->fetch()) {
$item = $this->getObject($row);
if (!$isCustom && !$item->validate()) {
continue;
}
$arr[] = $item;
}
return $arr;
}
private function _findAllGenerator(array $criteria = [], array $cols = []): Generator
{
$q = $this->db->select($this->table, $cols, $criteria, [
'orderby' => [$this->getSort() => $this->getOrder()],
]);
$isCustom = !empty($cols);
while ($row = $q->fetch()) {
$item = $this->getObject($row);
if (!$isCustom && !$item->validate()) {
continue;
}
yield $item;
}
}
private function _save(BasicModel $object, bool $useUUID = false, array $opts = []): mixed
{
$object->validate();
if ($object->hasPrimaryKey()) {
if ($arr = $object->diff(transform: true)) {
$this->db->transactional(function (DBLayer $db) use ($arr, $object) {
$db->update($this->table, $arr, [$object->getPrimaryKey() => $object->getPrimaryId()]);
$object->updatePrimaryData();
});
}
return $object->getPrimaryId();
}
if (true === ($isCustomID = is_a($this, IDInterface::class))) {
$object->{$object->getPrimaryKey()} = $this->makeId($object);
} elseif ($useUUID) {
$object->{$object->getPrimaryKey()} = generateUUID();
}
$this->db->transactional(function (DBLayer $db) use (&$object, $isCustomID, $useUUID) {
$obj = $object->getAll(transform: true);
$db->insert($this->table, $obj);
if (!$isCustomID && !$useUUID) {
$object->{$object->getPrimaryKey()} = (int)$db->id();
}
$object->updatePrimaryData();
});
return $object->getPrimaryId();
}
/**
* Save All Given Entities in one transaction.
*
* @param array<BasicModel> $items
* @param bool $useUUID
* @param array $opts
*
* @return array
*/
private function _saveAll(array $items, bool $useUUID = false, array $opts = []): array
{
return $this->db->transactional(function (DBLayer $db) use ($items, $useUUID) {
$ids = [];
$isCustomID = is_a($this, IDInterface::class);
foreach ($items as $object) {
if ($object->hasPrimaryKey()) {
if ($arr = $object->diff(transform: true)) {
$db->update($this->table, $arr, [$object->getPrimaryKey() => $object->getPrimaryId()]);
$object->updatePrimaryData();
}
$ids[$object->getPrimaryId()] = $object;
continue;
}
if (true === $isCustomID) {
/** @noinspection PhpUndefinedMethodInspection */
$object->{$object->getPrimaryKey()} = $this->makeId($object);
} elseif ($useUUID) {
$object->{$object->getPrimaryKey()} = generateUUID();
}
$obj = $object->getAll(transform: true);
$db->insert($this->table, $obj);
if (!$isCustomID && !$useUUID) {
$object->{$object->getPrimaryKey()} = (int)$db->id();
}
$object->updatePrimaryData();
$ids[$object->getPrimaryKey()] = $object;
}
return $ids;
});
}
private function _remove(BasicModel|array $criteria): bool
{
if ($criteria instanceof BasicModel) {
if (!$criteria->hasPrimaryKey()) {
throw new InvalidArgumentException(sprintf("'%s' has no primary key.", $criteria::class));
}
$criteria = [$criteria->getPrimaryKey() => $criteria->getPrimaryId()];
}
if (empty($criteria)) {
throw new InvalidArgumentException('\'$criteria\' cannot be empty.');
}
$count = 0;
$this->db->transactional(function (DBLayer $db) use (&$count, $criteria) {
$count = $db->delete($this->table, $criteria);
});
return (bool)$count;
}
private function _removeById(string|int $id, string $columnName = 'id'): bool
{
$this->db->transactional(fn(DBLayer $db) => $db->delete($this->table, [$columnName => $id]));
return true;
}
/**
* Save All Given Entities in one transaction.
*
* @param array<BasicModel> $items
* @param array $opts
*
* @return array
*/
private function _removeAll(array $items, array $opts = []): array
{
return $this->db->transactional(function (DBLayer $db) use ($items) {
$ids = [];
foreach ($items as $object) {
if (!$object->hasPrimaryKey()) {
continue;
}
$db->delete($this->table, [$object->getPrimaryKey() => $object->getPrimaryId()]);
$ids[] = $object->getPrimaryId();
}
return $ids;
});
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Model\Base\Traits;
trait UsesPaging
{
protected int $start = 0;
protected int $perpage = 15;
protected int $total = 0;
protected string $order = 'DESC';
protected string $sort = 'id';
public function setStart(int $start = 0): self
{
$this->start = $start;
return $this;
}
public function getStart(): int
{
return $this->start;
}
public function setPerpage(int $perpage = 15): self
{
$this->perpage = $perpage;
return $this;
}
public function getPerpage(): int
{
return $this->perpage;
}
public function setTotal(int $total = 0): self
{
$this->total = $total;
return $this;
}
public function getTotal(): int
{
return $this->total;
}
public function setAscendingOrder(): self
{
$this->order = 'ASC';
return $this;
}
public function setDescendingOrder(): self
{
$this->order = 'DESC';
return $this;
}
public function setSort($field): self
{
$this->sort = $field;
return $this;
}
public function getSort(): string
{
return $this->sort;
}
public function getOrder(): string
{
return $this->order;
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Model\Base\Transformers;
use App\Model\Base\Enums\TransformType;
final class ArrayTransformer
{
public function __construct(private bool $nullable = false)
{
}
public static function create(bool $nullable = false): callable
{
return JSONTransformer::create(isAssoc: true, nullable: $nullable);
}
public function __invoke(TransformType $type, mixed $data): string|array
{
return (new JSONTransformer(isAssoc: true, nullable: $this->nullable))(type: $type, data: $data);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Model\Base\Transformers;
use App\Model\Base\Enums\TransformType;
use DateTimeInterface;
use RuntimeException;
final readonly class DateTransformer
{
public function __construct(private bool $nullable = false)
{
}
public static function create(bool $nullable = false): callable
{
$class = new self($nullable);
return fn(TransformType $type, mixed $data) => $class($type, $data);
}
public function __invoke(TransformType $type, mixed $data): string|null|DateTimeInterface
{
if (null === $data) {
if ($this->nullable) {
return null;
}
throw new RuntimeException('Date cannot be null');
}
$isDate = true === ($data instanceof DateTimeInterface);
if (false === $isDate && !is_string($data)) {
if (true === ctype_digit((string)$data)) {
$isDate = true;
$data = makeDate($data);
} else {
throw new RuntimeException('Date must be a string or an instance of DateTimeInterface');
}
}
return match ($type) {
TransformType::ENCODE => $isDate ? $data->format(DateTimeInterface::ATOM) : (string)$data,
TransformType::DECODE => makeDate($data),
};
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Model\Base\Transformers;
use App\Model\Base\Enums\TransformType;
use BackedEnum;
final class EnumTransformer
{
/**
* @param string<class-string> $enumName The class name of the enum.
*/
public function __construct(private string $enumName)
{
}
public static function create(string $enumName): callable
{
$class = new self($enumName);
return fn(TransformType $type, mixed $data) => $class($type, $data);
}
public function __invoke(TransformType $type, mixed $value): mixed
{
return match ($type) {
TransformType::ENCODE => $this->encode($value),
TransformType::DECODE => $this->decode($value),
};
}
private function encode(mixed $value): string|int
{
if (is_string($value) || is_int($value)) {
return $value;
}
return $value instanceof BackedEnum ? $value->value : $value->name;
}
private function decode(mixed $data): mixed
{
return is_subclass_of($this->enumName, BackedEnum::class)
? ($this->enumName)::from($data)
: constant($this->enumName . '::' . $data);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Model\Base\Transformers;
use App\Model\Base\Enums\TransformType;
use InvalidArgumentException;
final class JSONTransformer
{
public const int DEFAULT_JSON_FLAGS = JSON_INVALID_UTF8_IGNORE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
public function __construct(
private bool $isAssoc = false,
private int $flags = self::DEFAULT_JSON_FLAGS,
private bool $nullable = false,
) {
}
public static function create(
bool $isAssoc = false,
int $flags = self::DEFAULT_JSON_FLAGS,
bool $nullable = false
): callable {
$class = new self(isAssoc: $isAssoc, flags: $flags, nullable: $nullable);
return fn(TransformType $type, mixed $data) => $class($type, $data);
}
public function __invoke(TransformType $type, mixed $data): string|array|object|null
{
if (null === $data) {
if (true === $this->nullable) {
return null;
}
throw new InvalidArgumentException('Data cannot be null');
}
return match ($type) {
TransformType::ENCODE => json_encode($data, flags: $this->flags),
TransformType::DECODE => json_decode($data, associative: $this->isAssoc, flags: $this->flags),
};
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Model\Base\Transformers;
use App\Model\Base\Enums\ScalarType;
use App\Model\Base\Enums\TransformType;
final class ScalarTransformer
{
public function __construct(private ScalarType $scalarType)
{
}
public static function create(ScalarType $scalarType): callable
{
$class = new self($scalarType);
return fn(TransformType $type, mixed $data) => $class($type, $data);
}
public function __invoke(TransformType $type, mixed $value): int|string|float|bool
{
return match ($type) {
TransformType::ENCODE => $this->encode($value),
TransformType::DECODE => $this->decode($value),
};
}
private function encode(mixed $value): float|bool|int|string
{
return $this->decode($value);
}
private function decode(mixed $data): float|bool|int|string
{
return match ($this->scalarType) {
ScalarType::STRING => (string)$data,
ScalarType::INT => (int)$data,
ScalarType::FLOAT => (float)$data,
ScalarType::BOOL => (bool)$data,
};
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Model\Base\Transformers;
use App\Model\Base\Enums\TransformType;
use Closure;
final class SerializeTransformer
{
private static Closure $encode;
private static Closure $decode;
public function __construct(bool $allowClasses = true)
{
if (extension_loaded('igbinary')) {
self::$encode = igbinary_serialize(...);
self::$decode = igbinary_unserialize(...);
} else {
self::$encode = serialize(...);
self::$decode = fn(string $data) => unserialize($data, ['allowed_classes' => $allowClasses]);
}
}
public function __invoke(TransformType $type, mixed $data): mixed
{
return match ($type) {
TransformType::ENCODE => (self::$encode)($data),
TransformType::DECODE => (self::$decode)($data),
};
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Model\Base\Transformers;
use App\Model\Base\Enums\TransformType;
use DateTimeInterface;
use RuntimeException;
final readonly class TimestampTransformer
{
public function __construct(private bool $nullable = false)
{
}
public static function create(bool $nullable = false): callable
{
$class = new self(nullable: $nullable);
return fn(TransformType $type, mixed $data) => $class($type, $data);
}
public function __invoke(TransformType $type, mixed $data): string|null|DateTimeInterface
{
if (null === $data) {
if ($this->nullable) {
return null;
}
throw new RuntimeException('Date cannot be null');
}
$isDate = true === ($data instanceof DateTimeInterface);
if (false === $isDate && !ctype_digit($data)) {
if (is_string($data)) {
$isDate = true;
$data = makeDate($data);
} else {
throw new RuntimeException(r("Date must be a integer or DateTime. '{type}('{data}')' given.", [
'type' => get_debug_type($data),
'data' => $data,
]));
}
}
return match ($type) {
TransformType::ENCODE => $isDate ? $data->getTimestamp() : $data,
TransformType::DECODE => $isDate ? $data : makeDate($data),
};
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Model\Events;
use App\libs\Extends\Date;
use App\Model\Base\Transformers\ArrayTransformer;
use App\Model\Base\Transformers\DateTransformer;
use App\Model\Base\Transformers\EnumTransformer;
use App\Model\Events\EventsTable as EntityTable;
use App\Model\Events\EventValidation as EntityValidation;
final class Event extends EntityTable
{
protected string $primaryKey = EntityTable::TABLE_PRIMARY_KEY;
/**
* @uses EntityTable::COLUMN_ID
*/
public string|null $id = null;
/**
* @uses EntityTable::COLUMN_STATUS
*/
public EventStatus $status = EventStatus::PENDING;
/**
* @uses EntityTable::COLUMN_EVENT
*/
public string $event = '';
/**
* @uses EntityTable::COLUMN_EVENT_DATA
*/
public array $event_data = [];
/**
* @uses EntityTable::COLUMN_OPTIONS
*/
public array $options = [];
/**
* @uses EntityTable::COLUMN_ATTEMPTS
*/
public int $attempts = 0;
/**
* @uses EntityTable::COLUMN_LOGS
*/
public array $logs = [];
/**
* @uses EntityTable::COLUMN_CREATED_AT
*/
public Date|string $created_at = '';
/**
* @uses EntityTable::COLUMN_UPDATED_AT
*/
public Date|string|null $updated_at = null;
protected function init(array &$data, bool &$isCustom, array &$options): void
{
$this->transform = [
EntityTable::COLUMN_STATUS => EnumTransformer::create(EventStatus::class),
EntityTable::COLUMN_EVENT_DATA => ArrayTransformer::class,
EntityTable::COLUMN_LOGS => ArrayTransformer::class,
EntityTable::COLUMN_OPTIONS => ArrayTransformer::class,
EntityTable::COLUMN_CREATED_AT => DateTransformer::class,
EntityTable::COLUMN_UPDATED_AT => DateTransformer::create(nullable: true),
];
}
public function getStatusText(): string
{
return ucfirst(strtolower($this->status->name));
}
public function validate(): bool
{
if ($this->isCustom) {
return false;
}
return (new EntityValidation($this))->isValid();
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Model\Events;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final readonly class EventListener
{
/**
* Listen to an event.
*
* @param string $event Event to listen to.
*/
public function __construct(public string $event)
{
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Model\Events;
enum EventStatus: int
{
case PENDING = 0;
case RUNNING = 1;
case SUCCESS = 2;
case FAILED = 3;
case CANCELLED = 4;
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Model\Events;
use App\Model\Base\BasicValidation;
use App\Model\Events\Event as EntityItem;
final class EventValidation extends BasicValidation
{
public function __construct(EntityItem $object)
{
$this->runValidator($object);
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Model\Events;
use App\Model\Base\Traits\UsesBasicRepository;
use App\Model\Events\Event as EntityItem;
use App\Model\Events\EventsTable as EntityTable;
final class EventsRepository
{
use UsesBasicRepository;
protected string $table = EntityTable::TABLE_NAME;
protected string $primaryKey = EntityTable::TABLE_PRIMARY_KEY;
public function getObject(array $row, bool $isCustom = false, array $opts = []): EntityItem
{
return (new EntityItem($row, $isCustom, $opts));
}
public function findOne(array $criteria): EntityItem|null
{
return $this->_findOne($criteria);
}
public function findById(string $id): EntityItem|null
{
return $this->_findOne([$this->primaryKey => $id]);
}
/**
* @param array $criteria Criteria to search by.
* @param array $cols Columns to select.
* @return array<EntityItem> empty array if no match found.
*/
public function findAll(array $criteria = [], array $cols = []): array
{
return $this->_findAll($criteria, $cols);
}
public function save(EntityItem $object): string
{
return $this->_save($object, useUUID: true);
}
/**
* @param array<EntityItem> $items
*
* @return array<string, EntityItem>
*/
public function saveAll(array $items): array
{
return $this->_saveAll($items, useUUID: true);
}
public function remove(EntityItem|array $criteria): bool
{
return $this->_remove($criteria);
}
public function removeById(string $id): bool
{
return $this->_removeById($id, $this->primaryKey);
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Model\Events;
use App\Model\Base\BasicModel;
abstract class EventsTable extends BasicModel
{
public const string TABLE_NAME = 'events';
public const string TABLE_PRIMARY_KEY = self::COLUMN_ID;
public const string COLUMN_ID = 'id';
public const string COLUMN_STATUS = 'status';
public const string COLUMN_EVENT = 'event';
public const string COLUMN_EVENT_DATA = 'event_data';
public const string COLUMN_OPTIONS = 'options';
public const string COLUMN_ATTEMPTS = 'attempts';
public const string COLUMN_LOGS = 'logs';
public const string COLUMN_CREATED_AT = 'created_at';
public const string COLUMN_UPDATED_AT = 'updated_at';
}