diff --git a/FAQ.md b/FAQ.md index fd20a18a..ece86b14 100644 --- a/FAQ.md +++ b/FAQ.md @@ -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. + diff --git a/composer.json b/composer.json index 2f7cf393..e2330a0f 100644 --- a/composer.json +++ b/composer.json @@ -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." diff --git a/composer.lock b/composer.lock index 6f11c23b..923ded1a 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/config.php b/config/config.php index b7de6b7e..5ed97dab 100644 --- a/config/config.php +++ b/config/config.php @@ -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/', + ] ], ]; diff --git a/config/services.php b/config/services.php index 46281601..7db05c73 100644 --- a/config/services.php +++ b/config/services.php @@ -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(), + ], + ]; })(); diff --git a/frontend/components/Markdown.vue b/frontend/components/Markdown.vue index 4fb0f941..4d0bf6a6 100644 --- a/frontend/components/Markdown.vue +++ b/frontend/components/Markdown.vue @@ -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) -})); +}); diff --git a/frontend/pages/tasks.vue b/frontend/pages/tasks.vue index e0337302..2f79e54d 100644 --- a/frontend/pages/tasks.vue +++ b/frontend/pages/tasks.vue @@ -50,7 +50,7 @@
{{ task.name }}
- + @@ -70,13 +70,21 @@
Timer:  - + + {{ task.timer }} + + {{ task.timer }}
Args:  - + + {{ task.args }} + + {{ task.args }}
@@ -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) { diff --git a/frontend/utils/index.js b/frontend/utils/index.js index 209b13e4..2edff8d9 100644 --- a/frontend/utils/index.js +++ b/frontend/utils/index.js @@ -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, } diff --git a/migrations/sqlite_1723926051_add_events_table.sql b/migrations/sqlite_1723926051_add_events_table.sql new file mode 100644 index 00000000..8a99d757 --- /dev/null +++ b/migrations/sqlite_1723926051_add_events_table.sql @@ -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"; diff --git a/src/API/Backend/Webhooks.php b/src/API/Backend/Webhooks.php index 2d825a4d..ead7cce4 100644 --- a/src/API/Backend/Webhooks.php +++ b/src/API/Backend/Webhooks.php @@ -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, ] ] ); diff --git a/src/API/Tasks/Index.php b/src/API/Tasks/Index.php index a25229ff..457db275 100644 --- a/src/API/Tasks/Index.php +++ b/src/API/Tasks/Index.php @@ -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()); diff --git a/src/Commands/Events/CacheCommand.php b/src/Commands/Events/CacheCommand.php new file mode 100644 index 00000000..7cc8047f --- /dev/null +++ b/src/Commands/Events/CacheCommand.php @@ -0,0 +1,29 @@ +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; + } +} diff --git a/src/Commands/Events/DispatchCommand.php b/src/Commands/Events/DispatchCommand.php new file mode 100644 index 00000000..c403ff7b --- /dev/null +++ b/src/Commands/Events/DispatchCommand.php @@ -0,0 +1,130 @@ +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); + } + } +} diff --git a/src/Commands/Events/ListenersCommand.php b/src/Commands/Events/ListenersCommand.php new file mode 100644 index 00000000..73ad390e --- /dev/null +++ b/src/Commands/Events/ListenersCommand.php @@ -0,0 +1,59 @@ +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; + } +} diff --git a/src/Commands/Events/QueuedCommand.php b/src/Commands/Events/QueuedCommand.php new file mode 100644 index 00000000..e72507c8 --- /dev/null +++ b/src/Commands/Events/QueuedCommand.php @@ -0,0 +1,69 @@ +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; + } +} diff --git a/src/Commands/System/PruneCommand.php b/src/Commands/System/PruneCommand.php index ee274614..913f1111 100644 --- a/src/Commands/System/PruneCommand.php +++ b/src/Commands/System/PruneCommand.php @@ -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]); + } + } } diff --git a/src/Libs/Attributes/Scanner/Attributes.php b/src/Libs/Attributes/Scanner/Attributes.php new file mode 100644 index 00000000..c11c92a1 --- /dev/null +++ b/src/Libs/Attributes/Scanner/Attributes.php @@ -0,0 +1,312 @@ + $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 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 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 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 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()); + } + }; + } +} diff --git a/src/Libs/Attributes/Scanner/Item.php b/src/Libs/Attributes/Scanner/Item.php new file mode 100644 index 00000000..a0a9ec72 --- /dev/null +++ b/src/Libs/Attributes/Scanner/Item.php @@ -0,0 +1,75 @@ +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; + } +} diff --git a/src/Libs/Attributes/Scanner/Target.php b/src/Libs/Attributes/Scanner/Target.php new file mode 100644 index 00000000..a6413983 --- /dev/null +++ b/src/Libs/Attributes/Scanner/Target.php @@ -0,0 +1,9 @@ + '', + '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 $cols + * @param array $conditions + * @param array $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; + } +} diff --git a/src/Libs/Events/DataEvent.php b/src/Libs/Events/DataEvent.php new file mode 100644 index 00000000..906761ba --- /dev/null +++ b/src/Libs/Events/DataEvent.php @@ -0,0 +1,33 @@ +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; + } +} diff --git a/src/Libs/Exceptions/DatabaseException.php b/src/Libs/Exceptions/DatabaseException.php index 5ebc3330..c12559dd 100644 --- a/src/Libs/Exceptions/DatabaseException.php +++ b/src/Libs/Exceptions/DatabaseException.php @@ -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; + } } diff --git a/src/Libs/Initializer.php b/src/Libs/Initializer.php index e4c0d9b9..4d07a4d0 100644 --- a/src/Libs/Initializer.php +++ b/src/Libs/Initializer.php @@ -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(), diff --git a/src/Libs/Mappers/Import/DirectMapper.php b/src/Libs/Mappers/Import/DirectMapper.php index f95c85fc..f9ce2e0d 100644 --- a/src/Libs/Mappers/Import/DirectMapper.php +++ b/src/Libs/Mappers/Import/DirectMapper.php @@ -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) { diff --git a/src/Libs/Mappers/Import/MemoryMapper.php b/src/Libs/Mappers/Import/MemoryMapper.php index 097a7c1a..3cf9cda4 100644 --- a/src/Libs/Mappers/Import/MemoryMapper.php +++ b/src/Libs/Mappers/Import/MemoryMapper.php @@ -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) { diff --git a/src/Libs/helpers.php b/src/Libs/helpers.php index 13f13894..438ab962 100644 --- a/src/Libs/helpers.php +++ b/src/Libs/helpers.php @@ -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 $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; + } +} diff --git a/src/Listeners/OnTestEvent.php b/src/Listeners/OnTestEvent.php new file mode 100644 index 00000000..54999f9c --- /dev/null +++ b/src/Listeners/OnTestEvent.php @@ -0,0 +1,20 @@ +stopPropagation(); + return $e; + } +} diff --git a/src/Model/Base/BasicModel.php b/src/Model/Base/BasicModel.php new file mode 100644 index 00000000..59081278 --- /dev/null +++ b/src/Model/Base/BasicModel.php @@ -0,0 +1,343 @@ + Copy of the original data. + */ + protected array $data = []; + + /** + * @var array 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 + */ + protected array $differ = []; + + /** + * @var array Casts for properties. + */ + protected array $casts = []; + + /** + * @var array> Properties for the class. + */ + private static array $_props = []; + + /** + * @var array> 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> + */ + 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; + } +} diff --git a/src/Model/Base/BasicValidation.php b/src/Model/Base/BasicValidation.php new file mode 100644 index 00000000..fe247dba --- /dev/null +++ b/src/Model/Base/BasicValidation.php @@ -0,0 +1,145 @@ +> Declared property datatype. Example [ 'id' => [ 'int', 'null' ] ] + */ + protected array $schemeDataType = []; + + /** + * @var array> Run custom validator on property. MUST return bool(true) to pass. + */ + protected array $schemeValidate = []; + + /** + * @var array> 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 + }; + } +} diff --git a/src/Model/Base/Enums/ScalarType.php b/src/Model/Base/Enums/ScalarType.php new file mode 100644 index 00000000..74cc2001 --- /dev/null +++ b/src/Model/Base/Enums/ScalarType.php @@ -0,0 +1,11 @@ +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 $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 $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; + }); + } +} diff --git a/src/Model/Base/Traits/UsesPaging.php b/src/Model/Base/Traits/UsesPaging.php new file mode 100644 index 00000000..99b9cf33 --- /dev/null +++ b/src/Model/Base/Traits/UsesPaging.php @@ -0,0 +1,82 @@ +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; + } + +} diff --git a/src/Model/Base/Transformers/ArrayTransformer.php b/src/Model/Base/Transformers/ArrayTransformer.php new file mode 100644 index 00000000..6781c6dd --- /dev/null +++ b/src/Model/Base/Transformers/ArrayTransformer.php @@ -0,0 +1,22 @@ +nullable))(type: $type, data: $data); + } +} diff --git a/src/Model/Base/Transformers/DateTransformer.php b/src/Model/Base/Transformers/DateTransformer.php new file mode 100644 index 00000000..03477205 --- /dev/null +++ b/src/Model/Base/Transformers/DateTransformer.php @@ -0,0 +1,47 @@ + $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), + }; + } +} diff --git a/src/Model/Base/Transformers/EnumTransformer.php b/src/Model/Base/Transformers/EnumTransformer.php new file mode 100644 index 00000000..7cb490db --- /dev/null +++ b/src/Model/Base/Transformers/EnumTransformer.php @@ -0,0 +1,46 @@ + $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); + } +} diff --git a/src/Model/Base/Transformers/JSONTransformer.php b/src/Model/Base/Transformers/JSONTransformer.php new file mode 100644 index 00000000..36c9506f --- /dev/null +++ b/src/Model/Base/Transformers/JSONTransformer.php @@ -0,0 +1,42 @@ + $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), + }; + } +} diff --git a/src/Model/Base/Transformers/ScalarTransformer.php b/src/Model/Base/Transformers/ScalarTransformer.php new file mode 100644 index 00000000..0d1d3b73 --- /dev/null +++ b/src/Model/Base/Transformers/ScalarTransformer.php @@ -0,0 +1,42 @@ + $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, + }; + } +} diff --git a/src/Model/Base/Transformers/SerializeTransformer.php b/src/Model/Base/Transformers/SerializeTransformer.php new file mode 100644 index 00000000..2ee2b2b3 --- /dev/null +++ b/src/Model/Base/Transformers/SerializeTransformer.php @@ -0,0 +1,31 @@ + 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), + }; + } +} diff --git a/src/Model/Base/Transformers/TimestampTransformer.php b/src/Model/Base/Transformers/TimestampTransformer.php new file mode 100644 index 00000000..d77e14cb --- /dev/null +++ b/src/Model/Base/Transformers/TimestampTransformer.php @@ -0,0 +1,50 @@ + $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), + }; + } +} diff --git a/src/Model/Events/Event.php b/src/Model/Events/Event.php new file mode 100644 index 00000000..33c0af0f --- /dev/null +++ b/src/Model/Events/Event.php @@ -0,0 +1,89 @@ +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(); + } + +} diff --git a/src/Model/Events/EventListener.php b/src/Model/Events/EventListener.php new file mode 100644 index 00000000..0e849360 --- /dev/null +++ b/src/Model/Events/EventListener.php @@ -0,0 +1,20 @@ +runValidator($object); + } +} diff --git a/src/Model/Events/EventsRepository.php b/src/Model/Events/EventsRepository.php new file mode 100644 index 00000000..34b38195 --- /dev/null +++ b/src/Model/Events/EventsRepository.php @@ -0,0 +1,68 @@ +_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 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 $items + * + * @return array + */ + 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); + } + +} diff --git a/src/Model/Events/EventsTable.php b/src/Model/Events/EventsTable.php new file mode 100644 index 00000000..ecbbe668 --- /dev/null +++ b/src/Model/Events/EventsTable.php @@ -0,0 +1,23 @@ +