Merge pull request #535 from arabcoders/dev
New DBLayer & new Events system
This commit is contained in:
55
FAQ.md
55
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.
|
||||
|
||||
|
||||
@@ -42,7 +42,9 @@
|
||||
"dragonmantank/cron-expression": "^3.3.2",
|
||||
"halaxa/json-machine": "^1.1.1",
|
||||
"league/route": "^5.1.2",
|
||||
"psy/psysh": "^0.11.22"
|
||||
"psy/psysh": "^0.11.22",
|
||||
"symfony/event-dispatcher": "^6.1.4",
|
||||
"ramsey/uuid": "^4.5.1"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-sockets": "For UDP commincations."
|
||||
|
||||
461
composer.lock
generated
461
composer.lock
generated
@@ -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",
|
||||
|
||||
@@ -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/',
|
||||
]
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
|
||||
];
|
||||
})();
|
||||
|
||||
@@ -17,7 +17,10 @@ const props = defineProps({
|
||||
const content = ref('')
|
||||
const api_url = useStorage('api_url', '')
|
||||
|
||||
onMounted(() => fetch(`${api_url.value}${props.file}`).then(response => response.text()).then(text => {
|
||||
onMounted(async () => {
|
||||
const response = await fetch(`${api_url.value}${props.file}?_=${Date.now()}`)
|
||||
const text = await response.text()
|
||||
|
||||
marked.use({
|
||||
gfm: true,
|
||||
renderer: {
|
||||
@@ -43,7 +46,8 @@ onMounted(() => fetch(`${api_url.value}${props.file}`).then(response => response
|
||||
},
|
||||
...baseUrl(api_url.value),
|
||||
});
|
||||
|
||||
content.value = marked.parse(text)
|
||||
}));
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<div class="is-capitalized card-header-title">
|
||||
{{ task.name }}
|
||||
</div>
|
||||
<span class="card-header-icon" v-tooltip="'Enable/Disable Task.'">
|
||||
<span class="card-header-icon" v-tooltip="'Enable/Disable Task.'" v-if="task.allow_disable">
|
||||
<input :id="task.name" type="checkbox" class="switch is-success" :checked="task.enabled"
|
||||
@change="toggleTask(task)">
|
||||
<label :for="task.name"></label>
|
||||
@@ -70,13 +70,21 @@
|
||||
</div>
|
||||
<div class="column is-6 has-text-left">
|
||||
<strong class="is-hidden-mobile">Timer: </strong>
|
||||
<NuxtLink class="has-tooltip" :to='makeEnvLink(`WS_CRON_${task.name.toUpperCase()}_AT`, task.timer)'>
|
||||
<span v-if="!task.allow_disabled" class="is-unselectable">
|
||||
{{ task.timer }}
|
||||
</span>
|
||||
<NuxtLink v-else class="has-tooltip"
|
||||
:to='makeEnvLink(`WS_CRON_${task.name.toUpperCase()}_AT`, task.timer)'>
|
||||
{{ task.timer }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="column is-6 has-text-right" v-if="task.args">
|
||||
<strong class="is-hidden-mobile">Args: </strong>
|
||||
<NuxtLink class="has-tooltip" :to='makeEnvLink(`WS_CRON_${task.name.toUpperCase()}_ARGS`, task.args)'>
|
||||
<span v-if="!task.allow_disabled" class="is-unselectable">
|
||||
{{ task.args }}
|
||||
</span>
|
||||
<NuxtLink v-else class="has-tooltip"
|
||||
:to='makeEnvLink(`WS_CRON_${task.name.toUpperCase()}_ARGS`, task.args)'>
|
||||
{{ task.args }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
@@ -162,7 +170,7 @@
|
||||
import 'assets/css/bulma-switch.css'
|
||||
import moment from 'moment'
|
||||
import request from '~/utils/request'
|
||||
import {awaitElement, makeConsoleCommand, notification, TOOLTIP_DATE_FORMAT} from '~/utils/index'
|
||||
import {awaitElement, makeConsoleCommand, notification, parse_api_response, TOOLTIP_DATE_FORMAT} from '~/utils/index'
|
||||
import cronstrue from 'cronstrue'
|
||||
import Message from '~/components/Message'
|
||||
import {useStorage} from '@vueuse/core'
|
||||
@@ -199,11 +207,21 @@ onMounted(async () => await loadContent())
|
||||
const toggleTask = async task => {
|
||||
try {
|
||||
const keyName = `WS_CRON_${task.name.toUpperCase()}`
|
||||
await request(`/system/env/${keyName}`, {
|
||||
|
||||
const oldState = task.enabled
|
||||
|
||||
const update = await request(`/system/env/${keyName}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({"value": !task.enabled})
|
||||
})
|
||||
|
||||
if (200 !== update.status) {
|
||||
const json = await parse_api_response(update)
|
||||
notification('error', 'Error', `Failed to toggle task '${task.name}' status. ${json.error.message}`)
|
||||
tasks.value[tasks.value.findIndex(b => b.name === task.name)].enabled = oldState
|
||||
return
|
||||
}
|
||||
|
||||
const response = await request(`/tasks/${task.name}`)
|
||||
tasks.value[tasks.value.findIndex(b => b.name === task.name)] = await response.json()
|
||||
} catch (e) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
22
migrations/sqlite_1723926051_add_events_table.sql
Normal file
22
migrations/sqlite_1723926051_add_events_table.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- # migrate_up
|
||||
|
||||
CREATE TABLE `events`
|
||||
(
|
||||
`id` char(36) NOT NULL,
|
||||
`status` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`event` varchar(255) NOT NULL,
|
||||
`event_data` longtext NOT NULL DEFAULT '{}',
|
||||
`options` longtext NOT NULL DEFAULT '{}',
|
||||
`attempts` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`logs` longtext NOT NULL DEFAULT '{}',
|
||||
`created_at` datetime NOT NULL,
|
||||
`updated_at` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
);
|
||||
|
||||
CREATE INDEX "events_event" ON "events" ("event");
|
||||
CREATE INDEX "events_status" ON "events" ("status");
|
||||
|
||||
-- # migrate_down
|
||||
|
||||
DROP TABLE IF EXISTS "events";
|
||||
@@ -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,
|
||||
]
|
||||
]
|
||||
);
|
||||
|
||||
@@ -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());
|
||||
|
||||
29
src/Commands/Events/CacheCommand.php
Normal file
29
src/Commands/Events/CacheCommand.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Commands\Events;
|
||||
|
||||
use App\Command;
|
||||
use App\Libs\Attributes\Route\Cli;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
#[Cli(command: self::ROUTE)]
|
||||
final class CacheCommand extends Command
|
||||
{
|
||||
public const string ROUTE = 'events:cache';
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName(self::ROUTE)
|
||||
->setDescription('Force cache invalidation for the events registrar.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
registerEvents(ignoreCache: true);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
130
src/Commands/Events/DispatchCommand.php
Normal file
130
src/Commands/Events/DispatchCommand.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Commands\Events;
|
||||
|
||||
use App\Command;
|
||||
use App\Libs\Attributes\Route\Cli;
|
||||
use App\Libs\Events\DataEvent;
|
||||
use App\Model\Events\Event;
|
||||
use App\Model\Events\EventsRepository;
|
||||
use App\Model\Events\EventsTable;
|
||||
use App\Model\Events\EventStatus as Status;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface as iDispatcher;
|
||||
use Psr\Log\LoggerInterface as iLogger;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||
use Throwable;
|
||||
|
||||
#[Cli(command: self::ROUTE)]
|
||||
final class DispatchCommand extends Command
|
||||
{
|
||||
public const string TASK_NAME = 'Dispatch';
|
||||
|
||||
public const string ROUTE = 'events:dispatch';
|
||||
|
||||
public function __construct(
|
||||
private readonly iDispatcher $dispatcher,
|
||||
private readonly EventsRepository $repo,
|
||||
private iLogger $logger,
|
||||
) {
|
||||
parent::__construct(null);
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName(self::ROUTE)
|
||||
->addOption('id', 'i', InputOption::VALUE_REQUIRED, 'Force run this event.')
|
||||
->addOption('reset', 'r', InputOption::VALUE_NONE, 'Reset event logs.')
|
||||
->setDescription('Run queued events.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
registerEvents();
|
||||
|
||||
$id = $input->getOption('id');
|
||||
if (null !== $id) {
|
||||
if (null === ($event = $this->repo->findById($id))) {
|
||||
$this->logger->error(r("Event with id '{id}' not found.", ['id' => $id]));
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($input->getOption('reset')) {
|
||||
$event->logs = [];
|
||||
}
|
||||
|
||||
$this->runEvent($event);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
return $this->runEvents();
|
||||
}
|
||||
|
||||
protected function runEvents(): int
|
||||
{
|
||||
$events = $this->repo->findAll([EventsTable::COLUMN_STATUS => Status::PENDING->value]);
|
||||
if (count($events) < 1) {
|
||||
$this->logger->debug('No pending queued events found.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
assert($this->dispatcher instanceof EventDispatcher);
|
||||
|
||||
foreach ($events as $event) {
|
||||
$this->runEvent($event);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function runEvent(Event $event): void
|
||||
{
|
||||
try {
|
||||
$message = "Dispatching Event: '{event}' queued at '{date}'.";
|
||||
$log_data = [
|
||||
'event' => $event->event,
|
||||
'date' => makeDate($event->created_at),
|
||||
];
|
||||
|
||||
$event->logs[] = r($message, $log_data);
|
||||
|
||||
if (count($event->event_data) > 0) {
|
||||
$log_data['data'] = $event->event_data;
|
||||
}
|
||||
|
||||
$this->logger->info($message, $log_data);
|
||||
|
||||
$event->status = Status::RUNNING;
|
||||
$event->updated_at = (string)makeDate();
|
||||
$event->attempts += 1;
|
||||
$this->repo->save($event);
|
||||
|
||||
$ref = new DataEvent($event);
|
||||
$this->dispatcher->dispatch($ref, $event->event);
|
||||
|
||||
$event->status = Status::SUCCESS;
|
||||
$event->updated_at = (string)makeDate();
|
||||
$event->logs[] = r("Event '{event}' was dispatched.", ['event' => $event->event]);
|
||||
|
||||
$this->repo->save($event);
|
||||
} catch (Throwable $e) {
|
||||
$errorLog = r("Failed to dispatch event: '{event}'. {error}", [
|
||||
'event' => ag($event, 'event'),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$event->logs[] = $errorLog;
|
||||
array_push($event->logs, ...$e->getTrace());
|
||||
$event->status = Status::FAILED;
|
||||
$event->updated_at = (string)makeDate();
|
||||
$this->repo->save($event);
|
||||
|
||||
$this->logger->error($errorLog);
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/Commands/Events/ListenersCommand.php
Normal file
59
src/Commands/Events/ListenersCommand.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Commands\Events;
|
||||
|
||||
use App\Command;
|
||||
use App\Libs\Attributes\Route\Cli;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface as iDispatcher;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||
|
||||
#[Cli(command: self::ROUTE)]
|
||||
final class ListenersCommand extends Command
|
||||
{
|
||||
public const string ROUTE = 'events:listeners';
|
||||
|
||||
public function __construct(private readonly iDispatcher $dispatcher)
|
||||
{
|
||||
parent::__construct(null);
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName(self::ROUTE)->setDescription('Show registered events Listeners.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$mode = $input->getOption('output');
|
||||
$keys = [];
|
||||
|
||||
assert($this->dispatcher instanceof EventDispatcher);
|
||||
foreach ($this->dispatcher->getListeners() as $key => $val) {
|
||||
$listeners = [];
|
||||
|
||||
foreach ($val as $listener) {
|
||||
$listeners[] = get_debug_type($listener);
|
||||
}
|
||||
|
||||
$keys[$key] = join(', ', $listeners);
|
||||
}
|
||||
|
||||
if ('table' === $mode) {
|
||||
$list = [];
|
||||
|
||||
foreach ($keys as $key => $val) {
|
||||
$list[] = ['Event' => $key, 'value' => $val];
|
||||
}
|
||||
|
||||
$keys = $list;
|
||||
}
|
||||
|
||||
$this->displayContent($keys, $output, $mode);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
69
src/Commands/Events/QueuedCommand.php
Normal file
69
src/Commands/Events/QueuedCommand.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Commands\Events;
|
||||
|
||||
use App\Command;
|
||||
use App\Libs\Attributes\Route\Cli;
|
||||
use App\Model\Events\EventsRepository;
|
||||
use App\Model\Events\EventsTable;
|
||||
use App\Model\Events\EventStatus as Status;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
#[Cli(command: self::ROUTE)]
|
||||
final class QueuedCommand extends Command
|
||||
{
|
||||
public const string ROUTE = 'events:queued';
|
||||
|
||||
public function __construct(private readonly EventsRepository $repo)
|
||||
{
|
||||
parent::__construct(null);
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName(self::ROUTE)
|
||||
->addOption('all', 'a', InputOption::VALUE_NONE, 'Show all.')
|
||||
->setDescription('Show queued events.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$filter = [
|
||||
EventsTable::COLUMN_STATUS => Status::PENDING->value
|
||||
];
|
||||
|
||||
if ($input->getOption('all')) {
|
||||
$filter = [];
|
||||
}
|
||||
|
||||
$events = $this->repo->findAll($filter);
|
||||
|
||||
$mode = $input->getOption('output');
|
||||
|
||||
if ('table' === $mode) {
|
||||
$list = [];
|
||||
|
||||
foreach ($events as $event) {
|
||||
$list[] = [
|
||||
'id' => $event->id,
|
||||
'event' => $event->event,
|
||||
'added' => $event->created_at,
|
||||
'status' => ucfirst(strtolower($event->status->name)),
|
||||
'Dispatched' => $event->updated_at ?? 'N/A',
|
||||
];
|
||||
}
|
||||
|
||||
$keys = $list;
|
||||
} else {
|
||||
$keys = array_map(fn($event) => $event->getAll(), $events);
|
||||
}
|
||||
|
||||
$this->displayContent($keys, $output, $mode);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
312
src/Libs/Attributes/Scanner/Attributes.php
Normal file
312
src/Libs/Attributes/Scanner/Attributes.php
Normal file
@@ -0,0 +1,312 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libs\Attributes\Scanner;
|
||||
|
||||
use Attribute;
|
||||
use Closure;
|
||||
use InvalidArgumentException;
|
||||
use Iterator;
|
||||
use PhpToken;
|
||||
use Psr\Log\LoggerAwareInterface;
|
||||
use Psr\Log\LoggerAwareTrait;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use ReflectionAttribute;
|
||||
use ReflectionClass;
|
||||
use RuntimeException;
|
||||
use SplHeap;
|
||||
use Throwable;
|
||||
|
||||
final class Attributes implements LoggerAwareInterface
|
||||
{
|
||||
use LoggerAwareTrait;
|
||||
|
||||
/**
|
||||
* Scan for attributes in given directories.
|
||||
*
|
||||
* @param array<string> $dirs List of directories to scan for php files.
|
||||
* @param bool $allowNonInvokable Allow non-invokable classes to be listed.
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function scan(array $dirs, bool $allowNonInvokable = false): self
|
||||
{
|
||||
return new self($dirs, $allowNonInvokable);
|
||||
}
|
||||
|
||||
private function __construct(private readonly array $dirs, private bool $allowNonInvokable)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan for attributes.
|
||||
*
|
||||
* @param Object|class-string $attribute Attribute to search for.
|
||||
* @param Closure|null $filter Filter to apply on returned data.
|
||||
*
|
||||
* @return array<array-key,Item> List of attributes found. Empty array if none found.
|
||||
* @throws \ReflectionException
|
||||
*/
|
||||
public function for(object|string $attribute, Closure|null $filter = null): array
|
||||
{
|
||||
$references = [];
|
||||
|
||||
$class = new ReflectionClass($attribute);
|
||||
$hasAttributes = $class->getAttributes(Attribute::class);
|
||||
if (empty($hasAttributes)) {
|
||||
throw new InvalidArgumentException(sprintf("The given class '%s' isn't a attribute.", $attribute));
|
||||
}
|
||||
|
||||
if (is_string($attribute)) {
|
||||
if (!$class->isInstantiable()) {
|
||||
throw new InvalidArgumentException(sprintf("Class '%s' is not instantiable.", $attribute));
|
||||
}
|
||||
$attribute = $class->newInstanceWithoutConstructor();
|
||||
}
|
||||
|
||||
foreach ($this->dirs as $path) {
|
||||
$this->logger?->debug("Scanning '{dir}' for '{attr}' attributes.", [
|
||||
'dir' => $path,
|
||||
'attr' => $attribute::class,
|
||||
]);
|
||||
array_push($references, ...$this->lookup($path, $attribute, $filter));
|
||||
}
|
||||
|
||||
return $references;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup for attributes in given directory.
|
||||
*
|
||||
* @param string $dir Directory to scan.
|
||||
* @param Object $attribute Attribute to search for.
|
||||
* @param Closure|null $filter Filter to apply on returned data.
|
||||
*
|
||||
* @return array<array-key,array{callable:string}> List of attributes found. Empty array if none found.
|
||||
*/
|
||||
private function lookup(string $dir, object $attribute, Closure|null $filter = null): array
|
||||
{
|
||||
$classes = $callables = [];
|
||||
|
||||
$it = $this->getSorter(
|
||||
new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
)
|
||||
);
|
||||
|
||||
foreach ($it as $file) {
|
||||
if (!$file->isFile() || 'php' !== $file->getExtension()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$class = $this->parse((string)$file);
|
||||
|
||||
if (empty($class)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
array_push($classes, ...$class);
|
||||
}
|
||||
|
||||
foreach ($classes as $className) {
|
||||
if (!class_exists($className)) {
|
||||
$this->logger?->warning(sprintf("Class '%s' not found.", $className));
|
||||
continue;
|
||||
}
|
||||
|
||||
array_push(
|
||||
$callables,
|
||||
...$this->find(new ReflectionClass($className), $attribute, $filter)
|
||||
);
|
||||
}
|
||||
|
||||
return $callables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find attributes in class.
|
||||
*
|
||||
* @param ReflectionClass $class Class to search.
|
||||
* @param Object $attribute Attribute to search for.
|
||||
* @param Closure|null $filter Filter to apply on returned data.
|
||||
*
|
||||
* @return array<array-key,array{callable:string}> List of attributes found. Empty array if none found.
|
||||
*/
|
||||
private function find(ReflectionClass $class, object $attribute, Closure|null $filter = null): array
|
||||
{
|
||||
$routes = [];
|
||||
|
||||
$attributes = $class->getAttributes($attribute::class, ReflectionAttribute::IS_INSTANCEOF);
|
||||
|
||||
$invokable = false;
|
||||
|
||||
foreach ($class->getMethods() as $method) {
|
||||
if ($method->getName() === '__invoke') {
|
||||
$invokable = true;
|
||||
}
|
||||
}
|
||||
|
||||
// -- for invokable classes.
|
||||
foreach ($attributes as $attrRef) {
|
||||
try {
|
||||
$attributeClass = $attrRef->newInstance();
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (!$attributeClass instanceof $attribute) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (false === $invokable && !$this->allowNonInvokable) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf(
|
||||
"Found attribute '%s' on non-invokable class. '%s'.",
|
||||
$attributeClass->pattern,
|
||||
$class->getName()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$item = [
|
||||
'target' => Target::IS_CLASS,
|
||||
'attribute' => $attributeClass::class,
|
||||
'callable' => $class->getName(),
|
||||
'data' => [],
|
||||
];
|
||||
|
||||
if (null !== $filter) {
|
||||
$filtered = $filter($attributeClass, $item);
|
||||
if (!empty($filtered)) {
|
||||
$item['data'] = $filtered;
|
||||
}
|
||||
} else {
|
||||
$item['data'] = get_object_vars($attributeClass);
|
||||
}
|
||||
|
||||
$routes[] = new Item(...$item);
|
||||
}
|
||||
|
||||
foreach ($class->getMethods() as $method) {
|
||||
$attributes = $method->getAttributes($attribute::class, ReflectionAttribute::IS_INSTANCEOF);
|
||||
|
||||
foreach ($attributes as $attrRef) {
|
||||
try {
|
||||
$attributeClass = $attrRef->newInstance();
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$attributeClass instanceof $attribute) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$call = '__invoke' === $method->getName() ? $class->getName() : [$class->getName(), $method->getName()];
|
||||
|
||||
$item = [
|
||||
'target' => Target::IS_METHOD,
|
||||
'attribute' => $attributeClass::class,
|
||||
'callable' => $call,
|
||||
'data' => [],
|
||||
];
|
||||
|
||||
if (null !== $filter) {
|
||||
$filtered = $filter($attributeClass, $item);
|
||||
if (!empty($filtered)) {
|
||||
$item['data'] = $filtered;
|
||||
}
|
||||
} else {
|
||||
$item['data'] = get_object_vars($attributeClass);
|
||||
}
|
||||
|
||||
$routes[] = new Item(...$item);
|
||||
}
|
||||
}
|
||||
|
||||
return $routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse file for classes.
|
||||
*
|
||||
* @param string $file File to parse.
|
||||
*
|
||||
* @return array List of classes. Empty array if none found.
|
||||
*/
|
||||
public function parse(string $file): array
|
||||
{
|
||||
$classes = [];
|
||||
$namespace = [];
|
||||
|
||||
try {
|
||||
$contents = file_get_contents($file);
|
||||
$tokens = PhpToken::tokenize($contents, TOKEN_PARSE);
|
||||
$count = count($tokens);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
throw new RuntimeException(sprintf("Unable to read/parse '%s'. %s", $file, $e->getMessage()));
|
||||
}
|
||||
|
||||
foreach ($tokens as $index => $token) {
|
||||
if ($token->is(T_NAMESPACE)) {
|
||||
for ($j = $index + 1; $j < $count; $j++) {
|
||||
if ($tokens[$j]->is(T_STRING)) {
|
||||
$namespace = $tokens[$j]->text;
|
||||
break;
|
||||
}
|
||||
|
||||
if ($tokens[$j]->is(T_NAME_QUALIFIED)) {
|
||||
$namespace = $tokens[$j]->text;
|
||||
break;
|
||||
}
|
||||
|
||||
if (';' === $tokens[$j]->getTokenName()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($token->is(T_CLASS)) {
|
||||
for ($j = $index + 1; $j < $count; $j++) {
|
||||
if ($tokens[$j]->is(T_WHITESPACE)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($tokens[$j]->is(T_STRING)) {
|
||||
$classes[] = $namespace . '\\' . $tokens[$j]->text;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count($classes) >= 1 ? $classes : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sorter for given iterator.
|
||||
*
|
||||
* @param Iterator $it Iterator to sort.
|
||||
*
|
||||
* @return SplHeap<RecursiveDirectoryIterator> Sorted iterator.
|
||||
*/
|
||||
private function getSorter(Iterator $it): SplHeap
|
||||
{
|
||||
return new class($it) extends SplHeap {
|
||||
public function __construct(Iterator $iterator)
|
||||
{
|
||||
foreach ($iterator as $item) {
|
||||
$this->insert($item);
|
||||
}
|
||||
}
|
||||
|
||||
public function compare($value1, $value2): int
|
||||
{
|
||||
return strcmp($value2->getRealpath(), $value1->getRealpath());
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
75
src/Libs/Attributes/Scanner/Item.php
Normal file
75
src/Libs/Attributes/Scanner/Item.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libs\Attributes\Scanner;
|
||||
|
||||
use App\Libs\Container;
|
||||
use Closure;
|
||||
|
||||
final readonly class Item
|
||||
{
|
||||
public function __construct(
|
||||
public Target $target,
|
||||
public string $attribute,
|
||||
public string|array|Closure $callable,
|
||||
public array $data = []
|
||||
) {
|
||||
}
|
||||
|
||||
public function getCallable(): string|array|Closure
|
||||
{
|
||||
return $this->callable;
|
||||
}
|
||||
|
||||
public function call(...$args): mixed
|
||||
{
|
||||
$callable = $this->callable;
|
||||
|
||||
if (is_string($callable) && str_contains($callable, '::')) {
|
||||
$callable = explode('::', $callable);
|
||||
}
|
||||
|
||||
if (is_array($callable) && isset($callable[0]) && is_object($callable[0])) {
|
||||
$callable = [$callable[0], $callable[1]];
|
||||
}
|
||||
|
||||
if (is_array($callable) && isset($callable[0]) && is_string($callable[0])) {
|
||||
$callable = [$this->resolve($callable[0]), $callable[1]];
|
||||
}
|
||||
|
||||
if (is_string($callable)) {
|
||||
$callable = $this->resolve($callable);
|
||||
}
|
||||
|
||||
return $callable(...$args);
|
||||
}
|
||||
|
||||
public function getTarget(): Target
|
||||
{
|
||||
return $this->target;
|
||||
}
|
||||
|
||||
public function getAttribute(): string
|
||||
{
|
||||
return $this->attribute;
|
||||
}
|
||||
|
||||
public function getData(): array
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
private function resolve(string $class)
|
||||
{
|
||||
if (Container::has($class)) {
|
||||
return Container::get($class);
|
||||
}
|
||||
|
||||
if (class_exists($class)) {
|
||||
return new $class();
|
||||
}
|
||||
|
||||
return $class;
|
||||
}
|
||||
}
|
||||
9
src/Libs/Attributes/Scanner/Target.php
Normal file
9
src/Libs/Attributes/Scanner/Target.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libs\Attributes\Scanner;
|
||||
|
||||
enum Target
|
||||
{
|
||||
case IS_CLASS;
|
||||
case IS_METHOD;
|
||||
}
|
||||
722
src/Libs/Database/DBLayer.php
Normal file
722
src/Libs/Database/DBLayer.php
Normal file
@@ -0,0 +1,722 @@
|
||||
<?php
|
||||
/** @noinspection PhpUnhandledExceptionInspection */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Libs\Database;
|
||||
|
||||
use App\Libs\Exceptions\DatabaseException as DBException;
|
||||
use Closure;
|
||||
use PDO;
|
||||
use PDOException;
|
||||
use PDOStatement;
|
||||
use RuntimeException;
|
||||
|
||||
final class DBLayer
|
||||
{
|
||||
private const int LOCK_RETRY = 4;
|
||||
|
||||
private int $count = 0;
|
||||
|
||||
private string $driver;
|
||||
|
||||
private array $last = [
|
||||
'sql' => '',
|
||||
'bind' => [],
|
||||
];
|
||||
|
||||
public const string WRITE_MODE = 'inWriteMode';
|
||||
|
||||
public const string IS_EQUAL = '=';
|
||||
public const string IS_LIKE = 'LIKE';
|
||||
public const string IS_IN = 'IN';
|
||||
public const string IS_NULL = 'IS NULL';
|
||||
public const string IS_HIGHER_THAN = '>';
|
||||
public const string IS_HIGHER_THAN_OR_EQUAL = '>=';
|
||||
public const string IS_LOWER_THAN = '<';
|
||||
public const string IS_LOWER_THAN_OR_EQUAL = '<=';
|
||||
public const string IS_NOT_EQUAL = '!=';
|
||||
public const string IS_NOT_LIKE = 'NOT LIKE';
|
||||
public const string IS_NOT_NULL = 'IS NOT NULL';
|
||||
public const string IS_NOT_IN = 'NOT IN';
|
||||
public const string IS_BETWEEN = 'BETWEEN';
|
||||
public const string IS_NOT_BETWEEN = 'NOT BETWEEN';
|
||||
public const string IS_LEFT_JOIN = 'LEFT JOIN';
|
||||
public const string IS_INNER_JOIN = 'INNER JOIN';
|
||||
public const string IS_LEFT_OUTER_JOIN = 'LEFT OUTER JOIN';
|
||||
public const string IS_MATCH_AGAINST = 'MATCH() AGAINST()';
|
||||
public const string IS_JSON_CONTAINS = 'JSON_CONTAINS';
|
||||
public const string IS_JSON_SEARCH = 'JSON_SEARCH';
|
||||
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
$driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||
|
||||
if (is_string($driver)) {
|
||||
$this->driver = $driver;
|
||||
}
|
||||
}
|
||||
|
||||
public function exec(string $sql, array $options = []): int|false
|
||||
{
|
||||
try {
|
||||
$queryString = $sql;
|
||||
|
||||
$this->last = [
|
||||
'sql' => $queryString,
|
||||
'bind' => [],
|
||||
];
|
||||
|
||||
$stmt = $this->pdo->exec($queryString);
|
||||
} catch (PDOException $e) {
|
||||
throw (new DBException($e->getMessage()))
|
||||
->setInfo($queryString, [], $e->errorInfo ?? [], $e->getCode())
|
||||
->setFile($e->getTrace()[$options['tracer'] ?? 1]['file'] ?? $e->getFile())
|
||||
->setLine($e->getTrace()[$options['tracer'] ?? 1]['line'] ?? $e->getLine())
|
||||
->setOptions([]);
|
||||
}
|
||||
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
public function query(string $queryString, array $bind = [], array $options = []): PDOStatement
|
||||
{
|
||||
try {
|
||||
$this->last = [
|
||||
'sql' => $queryString,
|
||||
'bind' => $bind,
|
||||
];
|
||||
|
||||
$stmt = $this->pdo->prepare($queryString);
|
||||
|
||||
if (!($stmt instanceof PDOStatement)) {
|
||||
throw new PDOException('Unable to prepare statement.');
|
||||
}
|
||||
|
||||
$stmt->execute($bind);
|
||||
|
||||
if (false !== stripos($queryString, 'SQL_CALC_FOUND_ROWS')) {
|
||||
if (false !== ($countStatement = $this->pdo->query('SELECT FOUND_ROWS();'))) {
|
||||
$this->count = (int)$countStatement->fetch(PDO::FETCH_COLUMN);
|
||||
}
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
throw (new DBException($e->getMessage()))
|
||||
->setInfo($queryString, $bind, $e->errorInfo ?? [], $e->getCode())
|
||||
->setFile($e->getTrace()[$options['tracer'] ?? 1]['file'] ?? $e->getFile())
|
||||
->setLine($e->getTrace()[$options['tracer'] ?? 1]['line'] ?? $e->getLine())
|
||||
->setOptions($options);
|
||||
}
|
||||
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
public function start(): bool
|
||||
{
|
||||
if ($this->pdo->inTransaction()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->pdo->beginTransaction();
|
||||
}
|
||||
|
||||
public function commit(): bool
|
||||
{
|
||||
return $this->pdo->commit();
|
||||
}
|
||||
|
||||
public function rollBack(): bool
|
||||
{
|
||||
return $this->pdo->rollBack();
|
||||
}
|
||||
|
||||
public function inTransaction(): bool
|
||||
{
|
||||
return $this->pdo->inTransaction();
|
||||
}
|
||||
|
||||
public function delete(string $table, array $conditions, array $options = []): PDOStatement
|
||||
{
|
||||
if (empty($conditions)) {
|
||||
throw new RuntimeException('Conditions Parameter is empty.');
|
||||
}
|
||||
|
||||
$query = [];
|
||||
$cond = $this->conditionParser($conditions);
|
||||
$bind = $cond['bind'];
|
||||
|
||||
$query[] = 'DELETE FROM ' . $this->escapeIdentifier($table, true) . ' WHERE';
|
||||
$query[] = implode(' AND ', $cond['query']);
|
||||
|
||||
if (array_key_exists('limit', $options)) {
|
||||
$_ = $this->limitExpr($options['limit']);
|
||||
|
||||
$query[] = $_['query'];
|
||||
$bind = array_replace_recursive($bind, $_['bind']);
|
||||
}
|
||||
|
||||
$query = array_map('trim', $query);
|
||||
|
||||
if (!array_key_exists('tracer', $options)) {
|
||||
$options['tracer'] = 2;
|
||||
}
|
||||
|
||||
return $this->query(implode(' ', $query), $bind, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select Statement.
|
||||
*
|
||||
* @param string $table
|
||||
* @param array<int,string> $cols
|
||||
* @param array<string,array|string|int|mixed> $conditions
|
||||
* @param array<string,mixed> $options
|
||||
*
|
||||
* @return PDOStatement
|
||||
*/
|
||||
public function select(string $table, array $cols = [], array $conditions = [], array $options = []): PDOStatement
|
||||
{
|
||||
$bind = [];
|
||||
$col = '*';
|
||||
|
||||
if (count($cols) >= 1) {
|
||||
$cols = array_map(
|
||||
function ($text) {
|
||||
if ('*' === $text) {
|
||||
return $text;
|
||||
}
|
||||
return $this->escapeIdentifier($text, true);
|
||||
},
|
||||
$cols
|
||||
);
|
||||
|
||||
$col = implode(', ', $cols);
|
||||
}
|
||||
|
||||
if (array_key_exists('count', $options) && $options['count']) {
|
||||
$this->getCount($table, $conditions, $options);
|
||||
}
|
||||
|
||||
$query = [];
|
||||
|
||||
$query[] = "SELECT {$col} FROM " . $this->escapeIdentifier($table, true);
|
||||
|
||||
|
||||
if (!empty($conditions)) {
|
||||
$andOr = $options['andor'] ?? 'AND';
|
||||
$cond = $this->conditionParser($conditions);
|
||||
|
||||
if (!empty($cond['query'])) {
|
||||
$query[] = 'WHERE ' . implode(" {$andOr} ", $cond['query']);
|
||||
}
|
||||
|
||||
$bind = array_replace_recursive($bind, $cond['bind']);
|
||||
}
|
||||
|
||||
if (array_key_exists('groupby', $options) && is_array($options['groupby'])) {
|
||||
$query[] = $this->groupByExpr($options['groupby'])['query'];
|
||||
}
|
||||
|
||||
if (array_key_exists('orderby', $options) && is_array($options['orderby'])) {
|
||||
$query[] = $this->orderByExpr($options['orderby'])['query'];
|
||||
}
|
||||
|
||||
if (array_key_exists('limit', $options)) {
|
||||
$_ = $this->limitExpr((int)$options['limit'], $options['start'] ?? null);
|
||||
|
||||
$query[] = $_['query'];
|
||||
$bind = array_replace_recursive($bind, $_['bind']);
|
||||
}
|
||||
|
||||
$query = array_map('trim', $query);
|
||||
|
||||
if (!array_key_exists('tracer', $options)) {
|
||||
$options['tracer'] = 2;
|
||||
}
|
||||
|
||||
return $this->query(implode(' ', $query), $bind, $options);
|
||||
}
|
||||
|
||||
public function getCount(string $table, array $conditions = [], array $options = []): void
|
||||
{
|
||||
$bind = $query = [];
|
||||
|
||||
$query[] = "SELECT COUNT(*) FROM " . $this->escapeIdentifier($table, true);
|
||||
|
||||
if (!empty($conditions)) {
|
||||
$cond = $this->conditionParser($conditions);
|
||||
|
||||
if (!empty($cond['query'])) {
|
||||
$query[] = 'WHERE ' . implode(' AND ', $cond['query']);
|
||||
}
|
||||
|
||||
$bind = $cond['bind'];
|
||||
}
|
||||
|
||||
if (array_key_exists('groupby', $options) && is_array($options['groupby'])) {
|
||||
$query[] = $this->groupByExpr($options['groupby'])['query'];
|
||||
}
|
||||
|
||||
if (array_key_exists('orderby', $options) && is_array($options['orderby'])) {
|
||||
$query[] = $this->orderByExpr($options['orderby'])['query'];
|
||||
}
|
||||
|
||||
$query = array_map('trim', $query);
|
||||
|
||||
if (!array_key_exists('tracer', $options)) {
|
||||
$options['tracer'] = 1;
|
||||
}
|
||||
|
||||
$this->count = (int)$this->query(implode(' ', $query), $bind, $options)->fetchColumn();
|
||||
}
|
||||
|
||||
public function update(string $table, array $changes, array $conditions, array $options = []): PDOStatement
|
||||
{
|
||||
if (empty($changes)) {
|
||||
throw new RuntimeException('Changes Parameter is empty.');
|
||||
}
|
||||
|
||||
if (empty($conditions)) {
|
||||
throw new RuntimeException('Conditions Parameter is empty.');
|
||||
}
|
||||
|
||||
$bind = $query = $updated = [];
|
||||
|
||||
$query[] = 'UPDATE ' . $this->escapeIdentifier($table, true) . ' SET';
|
||||
|
||||
foreach ($changes as $columnName => $columnValue) {
|
||||
$bindKey = '__dbu_' . $columnName;
|
||||
$bind[$bindKey] = $columnValue;
|
||||
$updated[] = sprintf(
|
||||
'%s = :%s',
|
||||
$this->escapeIdentifier($columnName, true),
|
||||
$this->escapeIdentifier($bindKey)
|
||||
);
|
||||
}
|
||||
|
||||
$query[] = implode(', ', $updated);
|
||||
|
||||
$cond = $this->conditionParser($conditions);
|
||||
$bind = array_replace_recursive($bind, $cond['bind']);
|
||||
|
||||
$query[] = 'WHERE ' . implode(' AND ', $cond['query']);
|
||||
|
||||
if (array_key_exists('limit', $options)) {
|
||||
$_ = $this->limitExpr((int)$options['limit']);
|
||||
|
||||
$query[] = $_['query'];
|
||||
$bind = array_replace_recursive($bind, $_['bind']);
|
||||
}
|
||||
|
||||
$query = array_map('trim', $query);
|
||||
|
||||
if (!array_key_exists('tracer', $options)) {
|
||||
$options['tracer'] = 1;
|
||||
}
|
||||
|
||||
return $this->query(implode(' ', $query), $bind, $options);
|
||||
}
|
||||
|
||||
public function insert(string $table, array $conditions, array $options = []): PDOStatement
|
||||
{
|
||||
if (empty($conditions)) {
|
||||
throw new RuntimeException('Conditions Parameter is empty, Expecting associative array.');
|
||||
}
|
||||
|
||||
$queryString = 'INSERT INTO ' . $this->escapeIdentifier($table, true) . ' ((columns)) VALUES((values))';
|
||||
|
||||
$columns = $placeholder = [];
|
||||
|
||||
foreach (array_keys($conditions) as $v) {
|
||||
$columns[] = $this->escapeIdentifier($v, true);
|
||||
$placeholder[] = sprintf(':%s', $this->escapeIdentifier($v, false));
|
||||
}
|
||||
|
||||
$queryString = str_replace(
|
||||
['(columns)', '(values)'],
|
||||
[implode(', ', $columns), implode(', ', $placeholder)],
|
||||
$queryString
|
||||
);
|
||||
|
||||
$queryString = trim($queryString);
|
||||
|
||||
if (!array_key_exists('tracer', $options)) {
|
||||
$options['tracer'] = 1;
|
||||
}
|
||||
|
||||
return $this->query($queryString, $conditions, $options);
|
||||
}
|
||||
|
||||
public function quote(mixed $text, int $type = PDO::PARAM_STR): string
|
||||
{
|
||||
return (string)$this->pdo->quote($text, $type);
|
||||
}
|
||||
|
||||
public function escape(string $text): string
|
||||
{
|
||||
return mb_substr($this->quote($text), 1, -1, 'UTF-8');
|
||||
}
|
||||
|
||||
public function id(string|null $name = null): string
|
||||
{
|
||||
return false !== ($id = $this->pdo->lastInsertId($name)) ? $id : '';
|
||||
}
|
||||
|
||||
public function totalRows(): int
|
||||
{
|
||||
return $this->count;
|
||||
}
|
||||
|
||||
public function close(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure only valid characters make it in column/table names
|
||||
*
|
||||
* @see https://stackoverflow.com/questions/10573922/what-does-the-sql-standard-say-about-usage-of-backtick
|
||||
*
|
||||
* @param string $text table or column name
|
||||
* @param bool $quote certain SQLs escape column names (i.e. mysql with `backticks`)
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function escapeIdentifier(string $text, bool $quote = false): string
|
||||
{
|
||||
// table or column has to be valid ASCII name.
|
||||
// this is opinionated, but we only allow [a-zA-Z0-9_] in column/table name.
|
||||
if (!preg_match('#\w#', $text)) {
|
||||
throw new RuntimeException(
|
||||
sprintf(
|
||||
'Invalid identifier "%s": Column/table must be valid ASCII code.',
|
||||
$text
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// The first character cannot be [0-9]:
|
||||
if (preg_match('/^\d/', $text)) {
|
||||
throw new RuntimeException(
|
||||
sprintf(
|
||||
'Invalid identifier "%s": Must begin with a letter or underscore.',
|
||||
$text
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if ($quote) {
|
||||
return match ($this->driver) {
|
||||
'mssql' => '[' . $text . ']',
|
||||
'mysql', 'mariadb' => '`' . $text . '`',
|
||||
default => '"' . $text . '"',
|
||||
};
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
public function getDriver(): string
|
||||
{
|
||||
return $this->driver;
|
||||
}
|
||||
|
||||
private function conditionParser(array $conditions): array
|
||||
{
|
||||
$keys = $bind = [];
|
||||
|
||||
foreach ($conditions as $column => $opt) {
|
||||
$column = trim($column);
|
||||
/** @noinspection PhpUnusedLocalVariableInspection */
|
||||
$eBindName = '__dbw_' . $this->escapeIdentifier($column);
|
||||
$eColumnName = $this->escapeIdentifier($column, true);
|
||||
|
||||
if (!is_array($opt)) {
|
||||
$opt = [self::IS_EQUAL, $opt];
|
||||
}
|
||||
|
||||
switch ($opt[0]) {
|
||||
case self::IS_EQUAL:
|
||||
case self::IS_NOT_EQUAL:
|
||||
case self::IS_HIGHER_THAN:
|
||||
case self::IS_HIGHER_THAN_OR_EQUAL:
|
||||
case self::IS_LOWER_THAN:
|
||||
case self::IS_LOWER_THAN_OR_EQUAL:
|
||||
$eBindName = '__db_cOp_' . random_int(1, 10000);
|
||||
$keys[] = str_replace(
|
||||
['(column)', '(bind)', '(expr)'],
|
||||
[
|
||||
$eColumnName,
|
||||
$eBindName,
|
||||
(function ($expr): string {
|
||||
return match ($expr) {
|
||||
self::IS_EQUAL => self::IS_EQUAL,
|
||||
self::IS_NOT_EQUAL => self::IS_NOT_EQUAL,
|
||||
self::IS_HIGHER_THAN => self::IS_HIGHER_THAN,
|
||||
self::IS_HIGHER_THAN_OR_EQUAL => self::IS_HIGHER_THAN_OR_EQUAL,
|
||||
self::IS_LOWER_THAN => self::IS_LOWER_THAN,
|
||||
self::IS_LOWER_THAN_OR_EQUAL => self::IS_LOWER_THAN_OR_EQUAL,
|
||||
default => throw new RuntimeException(sprintf('SQL (%s) not implemented.', $expr)),
|
||||
};
|
||||
})(
|
||||
$opt[0]
|
||||
)
|
||||
],
|
||||
'(column) (expr) :(bind)'
|
||||
);
|
||||
$bind[$eBindName] = $opt[1];
|
||||
break;
|
||||
case self::IS_BETWEEN:
|
||||
case self::IS_NOT_BETWEEN:
|
||||
$eBindName1 = ':__db_b1_' . random_int(1, 1000);
|
||||
$eBindName2 = ':__db_b2_' . random_int(1, 1000);
|
||||
$keys[] = str_replace(
|
||||
['(column)', '(bind1)', '(bind2)', '(expr)'],
|
||||
[
|
||||
$eColumnName,
|
||||
$eBindName1,
|
||||
$eBindName2,
|
||||
(function ($expr): string {
|
||||
return match ($expr) {
|
||||
self::IS_BETWEEN => self::IS_BETWEEN,
|
||||
self::IS_NOT_BETWEEN => self::IS_NOT_BETWEEN,
|
||||
default => throw new RuntimeException(sprintf('SQL (%s) not implemented.', $expr)),
|
||||
};
|
||||
})(
|
||||
$opt[0]
|
||||
)
|
||||
],
|
||||
"(column) (expr) (bind1) AND (bind2)"
|
||||
);
|
||||
$bind[$eBindName1] = $opt[1][0];
|
||||
$bind[$eBindName2] = $opt[1][1];
|
||||
break;
|
||||
case self::IS_NULL:
|
||||
case self::IS_NOT_NULL:
|
||||
$keys[] = str_replace(
|
||||
['(column)'],
|
||||
[$eColumnName],
|
||||
(function ($expr): string {
|
||||
return (self::IS_NULL === $expr) ? "(column) IS NULL" : "(column) IS NOT NULL";
|
||||
})(
|
||||
$opt[0]
|
||||
)
|
||||
);
|
||||
break;
|
||||
case self::IS_LIKE:
|
||||
case self::IS_NOT_LIKE:
|
||||
$eBindName = '__db_lk_' . random_int(1, 1000);
|
||||
$keys[] = str_replace(
|
||||
['(column)', '(bind)', '(expr)'],
|
||||
[
|
||||
$eColumnName,
|
||||
$eBindName,
|
||||
(function ($expr): string {
|
||||
return match ($expr) {
|
||||
self::IS_LIKE => self::IS_LIKE,
|
||||
self::IS_NOT_LIKE => self::IS_NOT_LIKE,
|
||||
default => throw new RuntimeException(sprintf('SQL (%s) not implemented.', $expr)),
|
||||
};
|
||||
})(
|
||||
$opt[0]
|
||||
)
|
||||
],
|
||||
(function ($driver) {
|
||||
if ('sqlite' === $driver) {
|
||||
return "(column) (expr) '%' || :(bind) || '%'";
|
||||
}
|
||||
return "(column) (expr) CONCAT('%',:(bind),'%')";
|
||||
})(
|
||||
$this->driver
|
||||
)
|
||||
);
|
||||
$bind[$eBindName] = $opt[1];
|
||||
break;
|
||||
case self::IS_IN:
|
||||
case self::IS_NOT_IN:
|
||||
$inExpr = $this->inExpr($column, $opt[1]);
|
||||
$keys[] = str_replace(
|
||||
['(column)', '(bind)', '(expr)'],
|
||||
[
|
||||
$eColumnName,
|
||||
$inExpr['query'],
|
||||
(function ($expr): string {
|
||||
return match ($expr) {
|
||||
self::IS_IN => self::IS_IN,
|
||||
self::IS_NOT_IN => self::IS_NOT_IN,
|
||||
default => throw new RuntimeException(sprintf('SQL (%s) not implemented.', $expr)),
|
||||
};
|
||||
})(
|
||||
$opt[0]
|
||||
)
|
||||
],
|
||||
"(column) (expr) ((bind))"
|
||||
);
|
||||
$bind = array_replace_recursive($bind, $inExpr['bind'] ?? []);
|
||||
break;
|
||||
case self::IS_MATCH_AGAINST:
|
||||
if (!isset($opt[1], $opt[2])) {
|
||||
throw new RuntimeException('IS_MATCH_AGAINST: expects 2 parameters.');
|
||||
}
|
||||
|
||||
if (!is_array($opt[1])) {
|
||||
throw new RuntimeException(
|
||||
sprintf('IS_MATCH_AGAINST: expects parameter 1 to be array. %s given.', gettype($opt[1]))
|
||||
);
|
||||
}
|
||||
|
||||
if (!is_string($opt[2])) {
|
||||
throw new RuntimeException(
|
||||
sprintf('IS_MATCH_AGAINST: expects parameter 2 to be string. %s given', gettype($opt[2]))
|
||||
);
|
||||
}
|
||||
|
||||
$eBindName = '__db_ftS_' . random_int(1, 1000);
|
||||
$keys[] = sprintf(
|
||||
"MATCH(%s) AGAINST(%s)",
|
||||
implode(', ', array_map(fn($columns) => $this->escapeIdentifier($columns, true), $opt[1])),
|
||||
':' . $eBindName
|
||||
);
|
||||
|
||||
$bind[$eBindName] = $opt[2];
|
||||
break;
|
||||
case self::IS_JSON_CONTAINS:
|
||||
if (!isset($opt[1], $opt[2])) {
|
||||
throw new RuntimeException('IS_JSON_CONTAINS: expects 2 parameters.');
|
||||
}
|
||||
|
||||
$eBindName = '__db_jc_' . random_int(1, 1000);
|
||||
|
||||
$keys[] = sprintf(
|
||||
"JSON_CONTAINS(%s, %s) > %d",
|
||||
$this->escapeIdentifier($opt[1], true),
|
||||
':' . $eBindName,
|
||||
(int)($opt[3] ?? 0)
|
||||
);
|
||||
|
||||
$bind[$eBindName] = $opt[2];
|
||||
break;
|
||||
case self::IS_INNER_JOIN:
|
||||
case self::IS_LEFT_JOIN:
|
||||
case self::IS_LEFT_OUTER_JOIN:
|
||||
default:
|
||||
throw new RuntimeException(sprintf('SQL (%s) expr not implemented.', $opt[0]));
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'bind' => $bind,
|
||||
'query' => $keys,
|
||||
];
|
||||
}
|
||||
|
||||
private function inExpr(string $key, array $parameters): array
|
||||
{
|
||||
$i = 0;
|
||||
$token = "__in_{$key}_";
|
||||
$bind = [];
|
||||
|
||||
foreach ($parameters as $param) {
|
||||
$i++;
|
||||
$bind[$token . $i] = $param;
|
||||
}
|
||||
|
||||
return [
|
||||
'bind' => $bind,
|
||||
'query' => ':' . implode(', :', array_keys($bind))
|
||||
];
|
||||
}
|
||||
|
||||
private function groupByExpr(array $groupBy): array
|
||||
{
|
||||
$groupBy = array_map(
|
||||
fn($val) => $this->escapeIdentifier($val, true),
|
||||
$groupBy
|
||||
);
|
||||
|
||||
return ['query' => 'GROUP BY ' . implode(', ', $groupBy)];
|
||||
}
|
||||
|
||||
private function orderByExpr(array $orderBy): array
|
||||
{
|
||||
$sortBy = [];
|
||||
|
||||
foreach ($orderBy as $columnName => $columnSort) {
|
||||
$columnSort = ('DESC' === strtoupper($columnSort)) ? 'DESC' : 'ASC';
|
||||
|
||||
$sortBy[] = $this->escapeIdentifier($columnName, true) . ' ' . $columnSort;
|
||||
}
|
||||
|
||||
return ['query' => 'ORDER BY ' . implode(', ', $sortBy)];
|
||||
}
|
||||
|
||||
private function limitExpr(int $limit, ?int $start = null): array
|
||||
{
|
||||
$bind = [
|
||||
'__db_limit' => $limit,
|
||||
];
|
||||
|
||||
if (is_int($start)) {
|
||||
$query = 'LIMIT :__db_start, :__db_limit';
|
||||
|
||||
$bind['__db_start'] = $start;
|
||||
} else {
|
||||
$query = 'LIMIT :__db_limit';
|
||||
}
|
||||
|
||||
return [
|
||||
'bind' => $bind,
|
||||
'query' => $query,
|
||||
];
|
||||
}
|
||||
|
||||
public function getLastStatement(): array
|
||||
{
|
||||
return $this->last;
|
||||
}
|
||||
|
||||
public function transactional(Closure $callback): mixed
|
||||
{
|
||||
$autoStartTransaction = false === $this->inTransaction();
|
||||
|
||||
for ($i = 1; $i <= self::LOCK_RETRY; $i++) {
|
||||
try {
|
||||
if (!$autoStartTransaction) {
|
||||
$this->start();
|
||||
}
|
||||
|
||||
$result = $callback($this);
|
||||
|
||||
if (!$autoStartTransaction) {
|
||||
$this->commit();
|
||||
}
|
||||
|
||||
$this->last = $this->getLastStatement();
|
||||
|
||||
return $result;
|
||||
} catch (DBException $e) {
|
||||
if (!$autoStartTransaction && $this->inTransaction()) {
|
||||
$this->rollBack();
|
||||
}
|
||||
|
||||
//-- sometimes sqlite is locked, therefore attempt to sleep until it's unlocked.
|
||||
if (false !== stripos($e->getMessage(), 'database is locked')) {
|
||||
// throw exception if happens self::LOCK_RETRY times in a row.
|
||||
if ($i >= self::LOCK_RETRY) {
|
||||
throw $e;
|
||||
}
|
||||
/** @noinspection PhpUnhandledExceptionInspection */
|
||||
sleep(self::LOCK_RETRY + random_int(1, 3));
|
||||
} else {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* We return in try or throw exception.
|
||||
* As such this return should never be reached.
|
||||
*/
|
||||
return null;
|
||||
}
|
||||
}
|
||||
33
src/Libs/Events/DataEvent.php
Normal file
33
src/Libs/Events/DataEvent.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libs\Events;
|
||||
|
||||
use App\Model\Events\Event as EventInfo;
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
class DataEvent extends Event
|
||||
{
|
||||
public function __construct(private readonly EventInfo $eventInfo)
|
||||
{
|
||||
}
|
||||
|
||||
public function getEvent(): EventInfo
|
||||
{
|
||||
return $this->eventInfo;
|
||||
}
|
||||
|
||||
public function addLog(string $log): void
|
||||
{
|
||||
$this->eventInfo->logs[] = $log;
|
||||
}
|
||||
|
||||
public function getLogs(): array
|
||||
{
|
||||
return $this->eventInfo->logs;
|
||||
}
|
||||
|
||||
public function getData(): array
|
||||
{
|
||||
return $this->eventInfo->event_data;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -7,14 +7,18 @@ use App\Backends\Common\Cache as BackendCache;
|
||||
use App\Backends\Common\ClientInterface as iClient;
|
||||
use App\Backends\Common\Context;
|
||||
use App\Libs\APIResponse;
|
||||
use App\Libs\Attributes\Scanner\Attributes as AttributesScanner;
|
||||
use App\Libs\Attributes\Scanner\Item as ScannerItem;
|
||||
use App\Libs\Config;
|
||||
use App\Libs\Container;
|
||||
use App\Libs\DataUtil;
|
||||
use App\Libs\Entity\StateInterface as iState;
|
||||
use App\Libs\Enums\Http\Status;
|
||||
use App\Libs\Events\DataEvent;
|
||||
use App\Libs\Exceptions\InvalidArgumentException;
|
||||
use App\Libs\Exceptions\RuntimeException;
|
||||
use App\Libs\Extends\Date;
|
||||
use App\Libs\Extends\ReflectionContainer;
|
||||
use App\Libs\Guid;
|
||||
use App\Libs\Initializer;
|
||||
use App\Libs\Options;
|
||||
@@ -22,15 +26,22 @@ use App\Libs\Response;
|
||||
use App\Libs\Router;
|
||||
use App\Libs\Stream;
|
||||
use App\Libs\Uri;
|
||||
use App\Model\Events\Event as EventInfo;
|
||||
use App\Model\Events\EventListener;
|
||||
use App\Model\Events\EventsRepository;
|
||||
use App\Model\Events\EventStatus;
|
||||
use Monolog\Utils;
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use Nyholm\Psr7Server\ServerRequestCreator;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Http\Message\ResponseInterface as iResponse;
|
||||
use Psr\Http\Message\ServerRequestInterface as iRequest;
|
||||
use Psr\Http\Message\StreamInterface as iStream;
|
||||
use Psr\Http\Message\UriInterface as iUri;
|
||||
use Psr\Log\LoggerInterface as iLogger;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
use Psr\SimpleCache\CacheInterface as iCache;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
|
||||
@@ -713,14 +724,19 @@ if (!function_exists('getAppVersion')) {
|
||||
$version = Config::get('version', 'dev-master');
|
||||
|
||||
if ('$(version_via_ci)' === $version) {
|
||||
$gitDir = ROOT_PATH . '/.git/';
|
||||
$gitDir = ROOT_PATH . DIRECTORY_SEPARATOR . '.git' . DIRECTORY_SEPARATOR;
|
||||
|
||||
if (is_dir($gitDir)) {
|
||||
$cmd = 'git --git-dir=%1$s describe --exact-match --tags 2> /dev/null || git --git-dir=%1$s rev-parse --short HEAD';
|
||||
exec(sprintf($cmd, escapeshellarg($gitDir)), $output, $status);
|
||||
|
||||
if (0 === $status) {
|
||||
return $output[0] ?? 'dev-master';
|
||||
$cmdVersion = [
|
||||
'git --git-dir=%1$s describe --exact-match --tags',
|
||||
'git --git-dir=%1$s rev-parse --short HEAD',
|
||||
];
|
||||
foreach ($cmdVersion as $cmd) {
|
||||
$proc = Process::fromShellCommandline(sprintf($cmd, escapeshellarg($gitDir)));
|
||||
$proc->run();
|
||||
if ($proc->isSuccessful()) {
|
||||
return explode(PHP_EOL, $proc->getOutput())[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1687,7 +1703,7 @@ if (!function_exists('restartTaskWorker')) {
|
||||
}
|
||||
|
||||
if (file_exists(r('/proc/{pid}/status', ['pid' => $pid]))) {
|
||||
@posix_kill((int)$pid, $force ? SIGKILL : SIGHUP);
|
||||
@posix_kill((int)$pid, $force ? 9 : 1);
|
||||
}
|
||||
|
||||
clearstatcache(true, $pidFile);
|
||||
@@ -1837,3 +1853,124 @@ if (!function_exists('ffprobe_file')) {
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
if (!function_exists('generateUUID')) {
|
||||
function generateUUID(string|int|null $prefix = null): string
|
||||
{
|
||||
$prefixUUID = '';
|
||||
|
||||
if (null !== $prefix) {
|
||||
$prefixUUID = $prefix ? $prefix . '-' : '';
|
||||
}
|
||||
|
||||
return $prefixUUID . Ramsey\Uuid\Uuid::uuid6()->toString();
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('cacheableItem')) {
|
||||
/**
|
||||
* Get Item From Cache or call Callable and cache result.
|
||||
*
|
||||
* @param string $key
|
||||
* @param Closure $function
|
||||
* @param DateInterval|int|null $ttl
|
||||
* @param bool $ignoreCache
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
function cacheableItem(
|
||||
string $key,
|
||||
Closure $function,
|
||||
DateInterval|int|null $ttl = null,
|
||||
bool $ignoreCache = false
|
||||
): mixed {
|
||||
$cache = Container::get(CacheInterface::class);
|
||||
|
||||
if (!$ignoreCache && $cache->has($key)) {
|
||||
return $cache->get($key);
|
||||
}
|
||||
|
||||
$reflectContainer = Container::get(ReflectionContainer::class);
|
||||
$item = $reflectContainer->call($function);
|
||||
|
||||
if (null === $ttl) {
|
||||
$ttl = new DateInterval('PT300S');
|
||||
}
|
||||
|
||||
$cache->set($key, $item, $ttl);
|
||||
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('registerEvents')) {
|
||||
/**
|
||||
* Register events.
|
||||
*/
|
||||
function registerEvents(bool $ignoreCache = false): void
|
||||
{
|
||||
static $alreadyRegistered = false;
|
||||
|
||||
if (false !== $alreadyRegistered) {
|
||||
return;
|
||||
}
|
||||
|
||||
$logger = Container::get(iLogger::class);
|
||||
$dispatcher = Container::get(EventDispatcherInterface::class);
|
||||
assert($dispatcher instanceof EventDispatcher);
|
||||
|
||||
/** @var array<ScannerItem> $list */
|
||||
$list = cacheableItem(
|
||||
'event_listeners',
|
||||
fn() => AttributesScanner::scan(Config::get('events.listeners.locations', []))->for(EventListener::class),
|
||||
Config::get('events.listeners.cache', fn() => new DateInterval('PT1H')),
|
||||
$ignoreCache
|
||||
);
|
||||
|
||||
foreach ($list as $item) {
|
||||
$dispatcher->addListener(ag($item->getData(), 'event'), $item->call(...));
|
||||
}
|
||||
|
||||
if (null !== ($eventsFile = Config::get('events.listeners.file'))) {
|
||||
try {
|
||||
foreach (require $eventsFile as $event) {
|
||||
$dispatcher->addListener(ag($event, 'on'), ag($event, 'callable'));
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$logger->error($e->getMessage(), []);
|
||||
}
|
||||
}
|
||||
|
||||
$alreadyRegistered = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('queueEvent')) {
|
||||
/**
|
||||
* Queue Event.
|
||||
*
|
||||
* @param string $event Event name.
|
||||
* @param array $data Event data.
|
||||
* @param array $opts Options.
|
||||
*
|
||||
* @return EventInfo
|
||||
*/
|
||||
function queueEvent(string $event, array $data = [], array $opts = []): EventInfo
|
||||
{
|
||||
$repo = ag($opts, EventsRepository::class, fn() => Container::get(EventsRepository::class));
|
||||
assert($repo instanceof EventsRepository);
|
||||
|
||||
$item = $repo->getObject([]);
|
||||
$item->event = $event;
|
||||
$item->status = EventStatus::PENDING;
|
||||
$item->event_data = $data;
|
||||
$item->created_at = makeDate();
|
||||
$item->options = [
|
||||
'class' => ag($opts, 'class', DataEvent::class),
|
||||
];
|
||||
|
||||
$id = $repo->save($item);
|
||||
$item->id = $id;
|
||||
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
|
||||
20
src/Listeners/OnTestEvent.php
Normal file
20
src/Listeners/OnTestEvent.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\libs\Events\DataEvent;
|
||||
use App\Model\Events\EventListener;
|
||||
|
||||
#[EventListener(self::NAME)]
|
||||
final readonly class OnTestEvent
|
||||
{
|
||||
public const string NAME = 'test_event';
|
||||
|
||||
public function __invoke(DataEvent $e): DataEvent
|
||||
{
|
||||
$e->stopPropagation();
|
||||
return $e;
|
||||
}
|
||||
}
|
||||
343
src/Model/Base/BasicModel.php
Normal file
343
src/Model/Base/BasicModel.php
Normal file
@@ -0,0 +1,343 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model\Base;
|
||||
|
||||
use App\Libs\Container;
|
||||
use App\Libs\Extends\Date;
|
||||
use App\Model\Base\Enums\TransformType;
|
||||
use InvalidArgumentException;
|
||||
use JsonSerializable;
|
||||
use ReflectionNamedType;
|
||||
use ReflectionObject;
|
||||
use ReflectionProperty;
|
||||
use ReflectionUnionType;
|
||||
use Stringable;
|
||||
|
||||
abstract class BasicModel implements jsonSerializable
|
||||
{
|
||||
/**
|
||||
* @var array<string,mixed> Copy of the original data.
|
||||
*/
|
||||
protected array $data = [];
|
||||
|
||||
/**
|
||||
* @var array<string, class-string|callable(TransformType $type, mixed $data):mixed> Transformations for properties.
|
||||
*/
|
||||
protected array $transform = [];
|
||||
|
||||
/**
|
||||
* Custom compare for complex data types. If the callable returns true,
|
||||
* the value is considered unchanged. Otherwise, it's considered changed.
|
||||
*
|
||||
* @var array<string, callable(mixed $old, mixed $new):bool>
|
||||
*/
|
||||
protected array $differ = [];
|
||||
|
||||
/**
|
||||
* @var array<string,string> Casts for properties.
|
||||
*/
|
||||
protected array $casts = [];
|
||||
|
||||
/**
|
||||
* @var array<class-string,array<string,array>> Properties for the class.
|
||||
*/
|
||||
private static array $_props = [];
|
||||
|
||||
/**
|
||||
* @var array<class-string,array<string,string>> Columns for the class.
|
||||
*/
|
||||
private static array $_columns = [];
|
||||
|
||||
/**
|
||||
* @var bool Loaded from DB.
|
||||
*/
|
||||
protected bool $fromDB = false;
|
||||
|
||||
/**
|
||||
* @var bool Whether the queried data is Custom.
|
||||
*/
|
||||
protected bool $isCustom = false;
|
||||
|
||||
/**
|
||||
* @var string Refers to table Unique ID.
|
||||
*/
|
||||
protected string $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Receive Data as key/value pairs.
|
||||
*
|
||||
* @param array $data
|
||||
* @param bool $isCustom If True Object **SHOULD NOT** pass checks.
|
||||
* @param array $options
|
||||
*/
|
||||
public function __construct(array $data = [], bool $isCustom = false, array $options = [])
|
||||
{
|
||||
$this->init($data, $isCustom, $options);
|
||||
|
||||
$this->isCustom = $isCustom;
|
||||
|
||||
if (array_key_exists('fromDB', $options)) {
|
||||
$this->fromDB = (bool)$options['fromDB'];
|
||||
}
|
||||
|
||||
if (array_key_exists('primaryKey', $options)) {
|
||||
$this->primaryKey = (string)$options['primaryKey'];
|
||||
}
|
||||
|
||||
$data = $this->transform(TransformType::DECODE, $data);
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
$value = $this->setValueType($key, $value);
|
||||
$this->{$key} = $value;
|
||||
$this->data[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
protected function init(array &$data, bool &$isCustom, array &$options): void
|
||||
{
|
||||
}
|
||||
|
||||
abstract public function validate(): bool;
|
||||
|
||||
public function isFromDB(?bool $fromDB = null): bool
|
||||
{
|
||||
if (null !== $fromDB) {
|
||||
$this->fromDB = $fromDB;
|
||||
}
|
||||
|
||||
return $this->fromDB;
|
||||
}
|
||||
|
||||
public function apply(BasicModel $model): static
|
||||
{
|
||||
foreach ($model->getAll() as $key => $value) {
|
||||
if ($key === $this->primaryKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->{$key} = $this->setValueType($key, $value);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function hasPrimaryKey(): bool
|
||||
{
|
||||
return property_exists($this, 'primaryKey') && !empty($this->{$this->primaryKey});
|
||||
}
|
||||
|
||||
public function getAll(bool $transform = false): array
|
||||
{
|
||||
$props = [];
|
||||
|
||||
$reflect = (new ReflectionObject($this))->getProperties(ReflectionProperty::IS_PUBLIC);
|
||||
|
||||
foreach ($reflect as $src) {
|
||||
$value = $src->getValue($this);
|
||||
|
||||
if ($value instanceof Stringable) {
|
||||
$value = (string)$value;
|
||||
}
|
||||
|
||||
$props[$src->getName()] = $value;
|
||||
}
|
||||
|
||||
return true === $transform ? $this->transform(TransformType::ENCODE, $props) : $props;
|
||||
}
|
||||
|
||||
public function diff(bool $deep = false, bool $transform = false): array
|
||||
{
|
||||
$changed = [];
|
||||
|
||||
foreach ($this->getAll() as $key => $value) {
|
||||
if (false === array_key_exists($key, $this->data)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$old = $this->data[$key];
|
||||
|
||||
// -- custom compare in case of complex data types.
|
||||
if (null !== ($fn = $this->differ[$key] ?? null) && true === (bool)$fn($old, $value)) {
|
||||
continue;
|
||||
} elseif ($value === $old) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$changed[$key] = false === $deep ? $value : [
|
||||
'old' => $old ?? null,
|
||||
'new' => $value,
|
||||
];
|
||||
}
|
||||
|
||||
if (true === $transform && !empty($changed)) {
|
||||
foreach ($changed as $key => $value) {
|
||||
if (false === $deep) {
|
||||
$changed[$key] = $this->transform(TransformType::ENCODE, [$key => $value])[$key];
|
||||
} else {
|
||||
$changed[$key] = [
|
||||
'old' => $this->transform(TransformType::ENCODE, [$key => $value['old']])[$key],
|
||||
'new' => $this->transform(TransformType::ENCODE, [$key => $value['new']])[$key],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Schema type for data Validation.
|
||||
*
|
||||
* This relies on Entity being strongly typed.
|
||||
*
|
||||
* @return array<string,array<string>>
|
||||
*/
|
||||
public function getSchemaDataType(): array
|
||||
{
|
||||
$className = get_class($this);
|
||||
|
||||
if (isset(self::$_props[$className])) {
|
||||
return self::$_props[$className];
|
||||
}
|
||||
|
||||
self::$_props[$className] = [];
|
||||
|
||||
$reflect = (new ReflectionObject($this))->getProperties(ReflectionProperty::IS_PUBLIC);
|
||||
|
||||
foreach ($reflect as $src) {
|
||||
$prop = $src->getType();
|
||||
$propName = $src->getName();
|
||||
|
||||
if (null === $prop) {
|
||||
self::$_props[$className][$propName] = ['mixed'];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($prop instanceof ReflectionNamedType) {
|
||||
self::$_props[$className][$propName][] = $prop->getName();
|
||||
|
||||
if ($prop->allowsNull()) {
|
||||
self::$_props[$className][$propName][] = 'null';
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($prop instanceof ReflectionUnionType) {
|
||||
foreach ($prop->getTypes() as $typed) {
|
||||
self::$_props[$className][$propName][] = $typed->getName();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return self::$_props[$className];
|
||||
}
|
||||
|
||||
public function setValueType(string $key, mixed $value): mixed
|
||||
{
|
||||
if (!isset($this->casts[$key])) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ('int' === $this->casts[$key] && ($value instanceof Date)) {
|
||||
$value = $value->getTimestamp();
|
||||
}
|
||||
|
||||
if (get_debug_type($value) === $this->casts[$key]) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
settype($value, $this->casts[$key]);
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function getColumnsNames(): array
|
||||
{
|
||||
$className = get_class($this);
|
||||
|
||||
if (isset(self::$_columns[$className])) {
|
||||
return self::$_columns[$className];
|
||||
}
|
||||
|
||||
self::$_columns[$className] = [];
|
||||
|
||||
foreach ((new ReflectionObject($this))->getConstants() as $key => $val) {
|
||||
if (!str_starts_with($key, 'COLUMN_')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
self::$_columns[$className][$key] = $val;
|
||||
}
|
||||
|
||||
return self::$_columns[$className];
|
||||
}
|
||||
|
||||
public function getPrimaryData(): array
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
public function updatePrimaryData(): self
|
||||
{
|
||||
$this->data = $this->getAll();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPrimaryId(): mixed
|
||||
{
|
||||
return $this->data[$this->primaryKey] ?? $this->{$this->primaryKey} ?? null;
|
||||
}
|
||||
|
||||
public function getPrimaryKey(): string
|
||||
{
|
||||
return $this->primaryKey;
|
||||
}
|
||||
|
||||
public function isCustom(): bool
|
||||
{
|
||||
return $this->isCustom;
|
||||
}
|
||||
|
||||
public function __debugInfo(): array
|
||||
{
|
||||
return $this->getAll();
|
||||
}
|
||||
|
||||
public function __destruct()
|
||||
{
|
||||
self::$_props = self::$_columns = [];
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return $this->getAll();
|
||||
}
|
||||
|
||||
protected function transform(TransformType $type, array $data): array
|
||||
{
|
||||
if (empty($this->transform)) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
foreach ($this->transform as $key => $callable) {
|
||||
if (false === array_key_exists($key, $data)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (false === is_callable($callable)) {
|
||||
if (true === is_string($callable) && true === class_exists($callable)) {
|
||||
$callable = Container::get($callable);
|
||||
} else {
|
||||
throw new InvalidArgumentException(sprintf("Transformer for '%s', is not callable.", $key));
|
||||
}
|
||||
}
|
||||
|
||||
$data[$key] = $callable($type, $data[$key]);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
145
src/Model/Base/BasicValidation.php
Normal file
145
src/Model/Base/BasicValidation.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model\Base;
|
||||
|
||||
use App\Libs\Extends\Date;
|
||||
use App\Model\Base\Exceptions\ValidationException;
|
||||
use App\Model\Base\Exceptions\VValidateException;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Stringable;
|
||||
|
||||
abstract class BasicValidation
|
||||
{
|
||||
/**
|
||||
* @var bool Whether the checks are successful.
|
||||
*/
|
||||
protected bool $isValid = false;
|
||||
|
||||
/**
|
||||
* @var array<string,array<string>> Declared property datatype. Example [ 'id' => [ 'int', 'null' ] ]
|
||||
*/
|
||||
protected array $schemeDataType = [];
|
||||
|
||||
/**
|
||||
* @var array<string,array<callable(mixed $value):bool>> Run custom validator on property. MUST return bool(true) to pass.
|
||||
*/
|
||||
protected array $schemeValidate = [];
|
||||
|
||||
/**
|
||||
* @var array<string,array<callable(mixed $value):mixed>> Run custom filter on property.
|
||||
*/
|
||||
protected array $schemeFilter = [];
|
||||
|
||||
protected function runValidator(BasicModel $model): void
|
||||
{
|
||||
$this->schemeDataType = array_replace_recursive($model->getSchemaDataType(), $this->schemeDataType);
|
||||
|
||||
foreach ($model->getAll() as $fieldName => $fieldValue) {
|
||||
if (!empty($this->schemeDataType)) {
|
||||
if (!array_key_exists($fieldName, $this->schemeDataType)) {
|
||||
throw new ValidationException(
|
||||
sprintf("'%s' is not part of '%s' data properties.", $fieldName, get_class($model))
|
||||
);
|
||||
}
|
||||
|
||||
$this->checkDataTypes($fieldName, $fieldValue);
|
||||
}
|
||||
|
||||
if (isset($this->schemeValidate[$fieldName])) {
|
||||
foreach ($this->schemeValidate[$fieldName] as $_fn) {
|
||||
if (true !== is_callable($_fn)) {
|
||||
throw new RuntimeException(
|
||||
sprintf("Validation Filter for '%s' is not a callable.", $fieldName)
|
||||
);
|
||||
}
|
||||
|
||||
if (true !== $_fn($fieldValue)) {
|
||||
throw new VValidateException(
|
||||
sprintf("Validation Filter for '%s' returned non-true.", $fieldName)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($this->schemeFilter[$fieldName])) {
|
||||
foreach ($this->schemeFilter[$fieldName] as $_fn) {
|
||||
if (true !== is_callable($_fn)) {
|
||||
throw new RuntimeException(sprintf("Data Filter for '%s' is not callable.", $fieldName));
|
||||
}
|
||||
$model->{$fieldName} = $_fn($fieldValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->isValid = true;
|
||||
}
|
||||
|
||||
protected function checkDataTypes(string $name, mixed $value): bool
|
||||
{
|
||||
if (!is_array($this->schemeDataType[$name])) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf(
|
||||
"Invalid data type returned from schemeDataType. expecting array. got '%s' instead.",
|
||||
gettype($value)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$passCheck = false;
|
||||
|
||||
foreach ($this->schemeDataType[$name] as $_type) {
|
||||
if ($this->checkType($value, $_type)) {
|
||||
$passCheck = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$passCheck) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf(
|
||||
"'%s' expects '%s' data type, but '%s' was given.",
|
||||
$name,
|
||||
implode(', ', $this->schemeDataType[$name]),
|
||||
get_debug_type($value)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Validation Checks out.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isValid(): bool
|
||||
{
|
||||
return $this->isValid;
|
||||
}
|
||||
|
||||
protected function checkType(mixed $value, string $type): bool
|
||||
{
|
||||
if ($type === gettype($value) || $type === get_debug_type($value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return match ($type) {
|
||||
'int', 'integer' => is_int($value),
|
||||
'string' => is_string($value),
|
||||
'bool', 'boolean' => is_bool($value),
|
||||
'double' => is_double($value),
|
||||
'float' => is_float($value),
|
||||
'array' => is_array($value),
|
||||
'null', 'NULL' => null === $value,
|
||||
'object' => is_object($value),
|
||||
'resource', 'resource (closed)' => is_resource($value),
|
||||
Stringable::class => $value instanceof Stringable,
|
||||
Date::class => $value instanceof Date,
|
||||
'mixed' => true,
|
||||
default => false
|
||||
};
|
||||
}
|
||||
}
|
||||
11
src/Model/Base/Enums/ScalarType.php
Normal file
11
src/Model/Base/Enums/ScalarType.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model\Base\Enums;
|
||||
|
||||
enum ScalarType
|
||||
{
|
||||
case STRING;
|
||||
case INT;
|
||||
case FLOAT;
|
||||
case BOOL;
|
||||
}
|
||||
10
src/Model/Base/Enums/TransformType.php
Normal file
10
src/Model/Base/Enums/TransformType.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model\Base\Enums;
|
||||
|
||||
enum TransformType
|
||||
{
|
||||
case ENCODE;
|
||||
|
||||
case DECODE;
|
||||
}
|
||||
16
src/Model/Base/Exceptions/MustBeNonEmpty.php
Normal file
16
src/Model/Base/Exceptions/MustBeNonEmpty.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model\Base\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* MustBeNonEmpty Thrown when Value is empty.
|
||||
*
|
||||
* @package Model\Base\Exceptions
|
||||
*/
|
||||
class MustBeNonEmpty extends RuntimeException
|
||||
{
|
||||
}
|
||||
15
src/Model/Base/Exceptions/VDateTypeException.php
Normal file
15
src/Model/Base/Exceptions/VDateTypeException.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model\Base\Exceptions;
|
||||
|
||||
/**
|
||||
* VDateTypeException Thrown when DataType check fails.
|
||||
*
|
||||
* @package Model\Base\Exceptions
|
||||
*/
|
||||
class VDateTypeException extends ValidationException
|
||||
{
|
||||
|
||||
}
|
||||
14
src/Model/Base/Exceptions/VFilterException.php
Normal file
14
src/Model/Base/Exceptions/VFilterException.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model\Base\Exceptions;
|
||||
|
||||
/**
|
||||
* VFilterException Thrown when filter fails.
|
||||
*
|
||||
* @package Model\Base\Exceptions
|
||||
*/
|
||||
class VFilterException extends ValidationException
|
||||
{
|
||||
}
|
||||
14
src/Model/Base/Exceptions/VValidateException.php
Normal file
14
src/Model/Base/Exceptions/VValidateException.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model\Base\Exceptions;
|
||||
|
||||
/**
|
||||
* VValidateException Thrown when Custom Validator fails.
|
||||
*
|
||||
* @package Model\Base\Exceptions
|
||||
*/
|
||||
class VValidateException extends ValidationException
|
||||
{
|
||||
}
|
||||
14
src/Model/Base/Exceptions/ValidationException.php
Normal file
14
src/Model/Base/Exceptions/ValidationException.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model\Base\Exceptions;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* All Validation Should Extend this class.
|
||||
*/
|
||||
class ValidationException extends InvalidArgumentException
|
||||
{
|
||||
}
|
||||
17
src/Model/Base/Interfaces/IDInterface.php
Normal file
17
src/Model/Base/Interfaces/IDInterface.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model\Base\Interfaces;
|
||||
|
||||
use App\Model\Base\BasicModel;
|
||||
|
||||
interface IDInterface
|
||||
{
|
||||
/**
|
||||
* Create a new ID.
|
||||
*
|
||||
* @param BasicModel $model The model to create an ID for.
|
||||
*
|
||||
* @return int|string
|
||||
*/
|
||||
public function makeId(BasicModel $model): int|string;
|
||||
}
|
||||
237
src/Model/Base/Traits/UsesBasicRepository.php
Normal file
237
src/Model/Base/Traits/UsesBasicRepository.php
Normal file
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model\Base\Traits;
|
||||
|
||||
use App\Libs\Database\DBLayer;
|
||||
use App\Model\Base\BasicModel;
|
||||
use App\Model\Base\Interfaces\IDInterface;
|
||||
use Generator;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
||||
trait UsesBasicRepository
|
||||
{
|
||||
use UsesPaging;
|
||||
|
||||
protected DBLayer $db;
|
||||
|
||||
public function __construct(DBLayer $db)
|
||||
{
|
||||
$this->init($db);
|
||||
|
||||
$this->db = $db;
|
||||
|
||||
if (empty($this->table)) {
|
||||
throw new RuntimeException('You must set table name in $this->table');
|
||||
}
|
||||
}
|
||||
|
||||
private function init(DBLayer $db): void
|
||||
{
|
||||
}
|
||||
|
||||
private function _findOne(array $criteria, array $cols = []): mixed
|
||||
{
|
||||
if (empty($criteria)) {
|
||||
throw new InvalidArgumentException('criteria is empty.');
|
||||
}
|
||||
|
||||
$q = $this->db->select($this->table, $cols, $criteria, ['limit' => 1])->fetch();
|
||||
|
||||
if (empty($q)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$isCustom = !empty($cols);
|
||||
|
||||
$item = $this->getObject($q, $isCustom);
|
||||
|
||||
return $isCustom || $item->validate() ? $item : null;
|
||||
}
|
||||
|
||||
private function _findAll(array $criteria = [], array $cols = []): array
|
||||
{
|
||||
$arr = [];
|
||||
|
||||
$q = $this->db->select($this->table, $cols, $criteria, [
|
||||
'count' => true,
|
||||
'start' => $this->getStart(),
|
||||
'limit' => $this->getPerpage(),
|
||||
'orderby' => [$this->getSort() => $this->getOrder()],
|
||||
]);
|
||||
|
||||
$isCustom = !empty($cols);
|
||||
|
||||
$this->setTotal($this->db->totalRows());
|
||||
|
||||
while ($row = $q->fetch()) {
|
||||
$item = $this->getObject($row);
|
||||
|
||||
if (!$isCustom && !$item->validate()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arr[] = $item;
|
||||
}
|
||||
|
||||
return $arr;
|
||||
}
|
||||
|
||||
private function _findAllGenerator(array $criteria = [], array $cols = []): Generator
|
||||
{
|
||||
$q = $this->db->select($this->table, $cols, $criteria, [
|
||||
'orderby' => [$this->getSort() => $this->getOrder()],
|
||||
]);
|
||||
|
||||
$isCustom = !empty($cols);
|
||||
|
||||
while ($row = $q->fetch()) {
|
||||
$item = $this->getObject($row);
|
||||
|
||||
if (!$isCustom && !$item->validate()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
yield $item;
|
||||
}
|
||||
}
|
||||
|
||||
private function _save(BasicModel $object, bool $useUUID = false, array $opts = []): mixed
|
||||
{
|
||||
$object->validate();
|
||||
|
||||
if ($object->hasPrimaryKey()) {
|
||||
if ($arr = $object->diff(transform: true)) {
|
||||
$this->db->transactional(function (DBLayer $db) use ($arr, $object) {
|
||||
$db->update($this->table, $arr, [$object->getPrimaryKey() => $object->getPrimaryId()]);
|
||||
$object->updatePrimaryData();
|
||||
});
|
||||
}
|
||||
return $object->getPrimaryId();
|
||||
}
|
||||
|
||||
if (true === ($isCustomID = is_a($this, IDInterface::class))) {
|
||||
$object->{$object->getPrimaryKey()} = $this->makeId($object);
|
||||
} elseif ($useUUID) {
|
||||
$object->{$object->getPrimaryKey()} = generateUUID();
|
||||
}
|
||||
|
||||
$this->db->transactional(function (DBLayer $db) use (&$object, $isCustomID, $useUUID) {
|
||||
$obj = $object->getAll(transform: true);
|
||||
$db->insert($this->table, $obj);
|
||||
|
||||
if (!$isCustomID && !$useUUID) {
|
||||
$object->{$object->getPrimaryKey()} = (int)$db->id();
|
||||
}
|
||||
|
||||
$object->updatePrimaryData();
|
||||
});
|
||||
|
||||
return $object->getPrimaryId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save All Given Entities in one transaction.
|
||||
*
|
||||
* @param array<BasicModel> $items
|
||||
* @param bool $useUUID
|
||||
* @param array $opts
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function _saveAll(array $items, bool $useUUID = false, array $opts = []): array
|
||||
{
|
||||
return $this->db->transactional(function (DBLayer $db) use ($items, $useUUID) {
|
||||
$ids = [];
|
||||
$isCustomID = is_a($this, IDInterface::class);
|
||||
|
||||
foreach ($items as $object) {
|
||||
if ($object->hasPrimaryKey()) {
|
||||
if ($arr = $object->diff(transform: true)) {
|
||||
$db->update($this->table, $arr, [$object->getPrimaryKey() => $object->getPrimaryId()]);
|
||||
$object->updatePrimaryData();
|
||||
}
|
||||
$ids[$object->getPrimaryId()] = $object;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (true === $isCustomID) {
|
||||
/** @noinspection PhpUndefinedMethodInspection */
|
||||
$object->{$object->getPrimaryKey()} = $this->makeId($object);
|
||||
} elseif ($useUUID) {
|
||||
$object->{$object->getPrimaryKey()} = generateUUID();
|
||||
}
|
||||
|
||||
$obj = $object->getAll(transform: true);
|
||||
|
||||
$db->insert($this->table, $obj);
|
||||
|
||||
if (!$isCustomID && !$useUUID) {
|
||||
$object->{$object->getPrimaryKey()} = (int)$db->id();
|
||||
}
|
||||
|
||||
$object->updatePrimaryData();
|
||||
|
||||
$ids[$object->getPrimaryKey()] = $object;
|
||||
}
|
||||
|
||||
return $ids;
|
||||
});
|
||||
}
|
||||
|
||||
private function _remove(BasicModel|array $criteria): bool
|
||||
{
|
||||
if ($criteria instanceof BasicModel) {
|
||||
if (!$criteria->hasPrimaryKey()) {
|
||||
throw new InvalidArgumentException(sprintf("'%s' has no primary key.", $criteria::class));
|
||||
}
|
||||
$criteria = [$criteria->getPrimaryKey() => $criteria->getPrimaryId()];
|
||||
}
|
||||
|
||||
if (empty($criteria)) {
|
||||
throw new InvalidArgumentException('\'$criteria\' cannot be empty.');
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
|
||||
$this->db->transactional(function (DBLayer $db) use (&$count, $criteria) {
|
||||
$count = $db->delete($this->table, $criteria);
|
||||
});
|
||||
|
||||
return (bool)$count;
|
||||
}
|
||||
|
||||
private function _removeById(string|int $id, string $columnName = 'id'): bool
|
||||
{
|
||||
$this->db->transactional(fn(DBLayer $db) => $db->delete($this->table, [$columnName => $id]));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save All Given Entities in one transaction.
|
||||
*
|
||||
* @param array<BasicModel> $items
|
||||
* @param array $opts
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function _removeAll(array $items, array $opts = []): array
|
||||
{
|
||||
return $this->db->transactional(function (DBLayer $db) use ($items) {
|
||||
$ids = [];
|
||||
|
||||
foreach ($items as $object) {
|
||||
if (!$object->hasPrimaryKey()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$db->delete($this->table, [$object->getPrimaryKey() => $object->getPrimaryId()]);
|
||||
$ids[] = $object->getPrimaryId();
|
||||
}
|
||||
|
||||
return $ids;
|
||||
});
|
||||
}
|
||||
}
|
||||
82
src/Model/Base/Traits/UsesPaging.php
Normal file
82
src/Model/Base/Traits/UsesPaging.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model\Base\Traits;
|
||||
|
||||
trait UsesPaging
|
||||
{
|
||||
protected int $start = 0;
|
||||
protected int $perpage = 15;
|
||||
protected int $total = 0;
|
||||
protected string $order = 'DESC';
|
||||
protected string $sort = 'id';
|
||||
|
||||
public function setStart(int $start = 0): self
|
||||
{
|
||||
$this->start = $start;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStart(): int
|
||||
{
|
||||
return $this->start;
|
||||
}
|
||||
|
||||
public function setPerpage(int $perpage = 15): self
|
||||
{
|
||||
$this->perpage = $perpage;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPerpage(): int
|
||||
{
|
||||
return $this->perpage;
|
||||
}
|
||||
|
||||
public function setTotal(int $total = 0): self
|
||||
{
|
||||
$this->total = $total;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTotal(): int
|
||||
{
|
||||
return $this->total;
|
||||
}
|
||||
|
||||
public function setAscendingOrder(): self
|
||||
{
|
||||
$this->order = 'ASC';
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setDescendingOrder(): self
|
||||
{
|
||||
$this->order = 'DESC';
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setSort($field): self
|
||||
{
|
||||
$this->sort = $field;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSort(): string
|
||||
{
|
||||
return $this->sort;
|
||||
}
|
||||
|
||||
public function getOrder(): string
|
||||
{
|
||||
return $this->order;
|
||||
}
|
||||
|
||||
}
|
||||
22
src/Model/Base/Transformers/ArrayTransformer.php
Normal file
22
src/Model/Base/Transformers/ArrayTransformer.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model\Base\Transformers;
|
||||
|
||||
use App\Model\Base\Enums\TransformType;
|
||||
|
||||
final class ArrayTransformer
|
||||
{
|
||||
public function __construct(private bool $nullable = false)
|
||||
{
|
||||
}
|
||||
|
||||
public static function create(bool $nullable = false): callable
|
||||
{
|
||||
return JSONTransformer::create(isAssoc: true, nullable: $nullable);
|
||||
}
|
||||
|
||||
public function __invoke(TransformType $type, mixed $data): string|array
|
||||
{
|
||||
return (new JSONTransformer(isAssoc: true, nullable: $this->nullable))(type: $type, data: $data);
|
||||
}
|
||||
}
|
||||
47
src/Model/Base/Transformers/DateTransformer.php
Normal file
47
src/Model/Base/Transformers/DateTransformer.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model\Base\Transformers;
|
||||
|
||||
use App\Model\Base\Enums\TransformType;
|
||||
use DateTimeInterface;
|
||||
use RuntimeException;
|
||||
|
||||
final readonly class DateTransformer
|
||||
{
|
||||
public function __construct(private bool $nullable = false)
|
||||
{
|
||||
}
|
||||
|
||||
public static function create(bool $nullable = false): callable
|
||||
{
|
||||
$class = new self($nullable);
|
||||
return fn(TransformType $type, mixed $data) => $class($type, $data);
|
||||
}
|
||||
|
||||
public function __invoke(TransformType $type, mixed $data): string|null|DateTimeInterface
|
||||
{
|
||||
if (null === $data) {
|
||||
if ($this->nullable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new RuntimeException('Date cannot be null');
|
||||
}
|
||||
|
||||
$isDate = true === ($data instanceof DateTimeInterface);
|
||||
|
||||
if (false === $isDate && !is_string($data)) {
|
||||
if (true === ctype_digit((string)$data)) {
|
||||
$isDate = true;
|
||||
$data = makeDate($data);
|
||||
} else {
|
||||
throw new RuntimeException('Date must be a string or an instance of DateTimeInterface');
|
||||
}
|
||||
}
|
||||
|
||||
return match ($type) {
|
||||
TransformType::ENCODE => $isDate ? $data->format(DateTimeInterface::ATOM) : (string)$data,
|
||||
TransformType::DECODE => makeDate($data),
|
||||
};
|
||||
}
|
||||
}
|
||||
46
src/Model/Base/Transformers/EnumTransformer.php
Normal file
46
src/Model/Base/Transformers/EnumTransformer.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model\Base\Transformers;
|
||||
|
||||
use App\Model\Base\Enums\TransformType;
|
||||
use BackedEnum;
|
||||
|
||||
final class EnumTransformer
|
||||
{
|
||||
/**
|
||||
* @param string<class-string> $enumName The class name of the enum.
|
||||
*/
|
||||
public function __construct(private string $enumName)
|
||||
{
|
||||
}
|
||||
|
||||
public static function create(string $enumName): callable
|
||||
{
|
||||
$class = new self($enumName);
|
||||
return fn(TransformType $type, mixed $data) => $class($type, $data);
|
||||
}
|
||||
|
||||
public function __invoke(TransformType $type, mixed $value): mixed
|
||||
{
|
||||
return match ($type) {
|
||||
TransformType::ENCODE => $this->encode($value),
|
||||
TransformType::DECODE => $this->decode($value),
|
||||
};
|
||||
}
|
||||
|
||||
private function encode(mixed $value): string|int
|
||||
{
|
||||
if (is_string($value) || is_int($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return $value instanceof BackedEnum ? $value->value : $value->name;
|
||||
}
|
||||
|
||||
private function decode(mixed $data): mixed
|
||||
{
|
||||
return is_subclass_of($this->enumName, BackedEnum::class)
|
||||
? ($this->enumName)::from($data)
|
||||
: constant($this->enumName . '::' . $data);
|
||||
}
|
||||
}
|
||||
42
src/Model/Base/Transformers/JSONTransformer.php
Normal file
42
src/Model/Base/Transformers/JSONTransformer.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model\Base\Transformers;
|
||||
|
||||
use App\Model\Base\Enums\TransformType;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class JSONTransformer
|
||||
{
|
||||
public const int DEFAULT_JSON_FLAGS = JSON_INVALID_UTF8_IGNORE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
|
||||
|
||||
public function __construct(
|
||||
private bool $isAssoc = false,
|
||||
private int $flags = self::DEFAULT_JSON_FLAGS,
|
||||
private bool $nullable = false,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function create(
|
||||
bool $isAssoc = false,
|
||||
int $flags = self::DEFAULT_JSON_FLAGS,
|
||||
bool $nullable = false
|
||||
): callable {
|
||||
$class = new self(isAssoc: $isAssoc, flags: $flags, nullable: $nullable);
|
||||
return fn(TransformType $type, mixed $data) => $class($type, $data);
|
||||
}
|
||||
|
||||
public function __invoke(TransformType $type, mixed $data): string|array|object|null
|
||||
{
|
||||
if (null === $data) {
|
||||
if (true === $this->nullable) {
|
||||
return null;
|
||||
}
|
||||
throw new InvalidArgumentException('Data cannot be null');
|
||||
}
|
||||
|
||||
return match ($type) {
|
||||
TransformType::ENCODE => json_encode($data, flags: $this->flags),
|
||||
TransformType::DECODE => json_decode($data, associative: $this->isAssoc, flags: $this->flags),
|
||||
};
|
||||
}
|
||||
}
|
||||
42
src/Model/Base/Transformers/ScalarTransformer.php
Normal file
42
src/Model/Base/Transformers/ScalarTransformer.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model\Base\Transformers;
|
||||
|
||||
use App\Model\Base\Enums\ScalarType;
|
||||
use App\Model\Base\Enums\TransformType;
|
||||
|
||||
final class ScalarTransformer
|
||||
{
|
||||
public function __construct(private ScalarType $scalarType)
|
||||
{
|
||||
}
|
||||
|
||||
public static function create(ScalarType $scalarType): callable
|
||||
{
|
||||
$class = new self($scalarType);
|
||||
return fn(TransformType $type, mixed $data) => $class($type, $data);
|
||||
}
|
||||
|
||||
public function __invoke(TransformType $type, mixed $value): int|string|float|bool
|
||||
{
|
||||
return match ($type) {
|
||||
TransformType::ENCODE => $this->encode($value),
|
||||
TransformType::DECODE => $this->decode($value),
|
||||
};
|
||||
}
|
||||
|
||||
private function encode(mixed $value): float|bool|int|string
|
||||
{
|
||||
return $this->decode($value);
|
||||
}
|
||||
|
||||
private function decode(mixed $data): float|bool|int|string
|
||||
{
|
||||
return match ($this->scalarType) {
|
||||
ScalarType::STRING => (string)$data,
|
||||
ScalarType::INT => (int)$data,
|
||||
ScalarType::FLOAT => (float)$data,
|
||||
ScalarType::BOOL => (bool)$data,
|
||||
};
|
||||
}
|
||||
}
|
||||
31
src/Model/Base/Transformers/SerializeTransformer.php
Normal file
31
src/Model/Base/Transformers/SerializeTransformer.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model\Base\Transformers;
|
||||
|
||||
use App\Model\Base\Enums\TransformType;
|
||||
use Closure;
|
||||
|
||||
final class SerializeTransformer
|
||||
{
|
||||
private static Closure $encode;
|
||||
private static Closure $decode;
|
||||
|
||||
public function __construct(bool $allowClasses = true)
|
||||
{
|
||||
if (extension_loaded('igbinary')) {
|
||||
self::$encode = igbinary_serialize(...);
|
||||
self::$decode = igbinary_unserialize(...);
|
||||
} else {
|
||||
self::$encode = serialize(...);
|
||||
self::$decode = fn(string $data) => unserialize($data, ['allowed_classes' => $allowClasses]);
|
||||
}
|
||||
}
|
||||
|
||||
public function __invoke(TransformType $type, mixed $data): mixed
|
||||
{
|
||||
return match ($type) {
|
||||
TransformType::ENCODE => (self::$encode)($data),
|
||||
TransformType::DECODE => (self::$decode)($data),
|
||||
};
|
||||
}
|
||||
}
|
||||
50
src/Model/Base/Transformers/TimestampTransformer.php
Normal file
50
src/Model/Base/Transformers/TimestampTransformer.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model\Base\Transformers;
|
||||
|
||||
use App\Model\Base\Enums\TransformType;
|
||||
use DateTimeInterface;
|
||||
use RuntimeException;
|
||||
|
||||
final readonly class TimestampTransformer
|
||||
{
|
||||
public function __construct(private bool $nullable = false)
|
||||
{
|
||||
}
|
||||
|
||||
public static function create(bool $nullable = false): callable
|
||||
{
|
||||
$class = new self(nullable: $nullable);
|
||||
return fn(TransformType $type, mixed $data) => $class($type, $data);
|
||||
}
|
||||
|
||||
public function __invoke(TransformType $type, mixed $data): string|null|DateTimeInterface
|
||||
{
|
||||
if (null === $data) {
|
||||
if ($this->nullable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new RuntimeException('Date cannot be null');
|
||||
}
|
||||
|
||||
$isDate = true === ($data instanceof DateTimeInterface);
|
||||
|
||||
if (false === $isDate && !ctype_digit($data)) {
|
||||
if (is_string($data)) {
|
||||
$isDate = true;
|
||||
$data = makeDate($data);
|
||||
} else {
|
||||
throw new RuntimeException(r("Date must be a integer or DateTime. '{type}('{data}')' given.", [
|
||||
'type' => get_debug_type($data),
|
||||
'data' => $data,
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
return match ($type) {
|
||||
TransformType::ENCODE => $isDate ? $data->getTimestamp() : $data,
|
||||
TransformType::DECODE => $isDate ? $data : makeDate($data),
|
||||
};
|
||||
}
|
||||
}
|
||||
89
src/Model/Events/Event.php
Normal file
89
src/Model/Events/Event.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model\Events;
|
||||
|
||||
use App\libs\Extends\Date;
|
||||
use App\Model\Base\Transformers\ArrayTransformer;
|
||||
use App\Model\Base\Transformers\DateTransformer;
|
||||
use App\Model\Base\Transformers\EnumTransformer;
|
||||
use App\Model\Events\EventsTable as EntityTable;
|
||||
use App\Model\Events\EventValidation as EntityValidation;
|
||||
|
||||
final class Event extends EntityTable
|
||||
{
|
||||
protected string $primaryKey = EntityTable::TABLE_PRIMARY_KEY;
|
||||
|
||||
/**
|
||||
* @uses EntityTable::COLUMN_ID
|
||||
*/
|
||||
public string|null $id = null;
|
||||
|
||||
/**
|
||||
* @uses EntityTable::COLUMN_STATUS
|
||||
*/
|
||||
public EventStatus $status = EventStatus::PENDING;
|
||||
|
||||
/**
|
||||
* @uses EntityTable::COLUMN_EVENT
|
||||
*/
|
||||
public string $event = '';
|
||||
|
||||
/**
|
||||
* @uses EntityTable::COLUMN_EVENT_DATA
|
||||
*/
|
||||
public array $event_data = [];
|
||||
|
||||
/**
|
||||
* @uses EntityTable::COLUMN_OPTIONS
|
||||
*/
|
||||
public array $options = [];
|
||||
|
||||
/**
|
||||
* @uses EntityTable::COLUMN_ATTEMPTS
|
||||
*/
|
||||
public int $attempts = 0;
|
||||
|
||||
/**
|
||||
* @uses EntityTable::COLUMN_LOGS
|
||||
*/
|
||||
public array $logs = [];
|
||||
|
||||
/**
|
||||
* @uses EntityTable::COLUMN_CREATED_AT
|
||||
*/
|
||||
public Date|string $created_at = '';
|
||||
|
||||
/**
|
||||
* @uses EntityTable::COLUMN_UPDATED_AT
|
||||
*/
|
||||
public Date|string|null $updated_at = null;
|
||||
|
||||
protected function init(array &$data, bool &$isCustom, array &$options): void
|
||||
{
|
||||
$this->transform = [
|
||||
EntityTable::COLUMN_STATUS => EnumTransformer::create(EventStatus::class),
|
||||
EntityTable::COLUMN_EVENT_DATA => ArrayTransformer::class,
|
||||
EntityTable::COLUMN_LOGS => ArrayTransformer::class,
|
||||
EntityTable::COLUMN_OPTIONS => ArrayTransformer::class,
|
||||
EntityTable::COLUMN_CREATED_AT => DateTransformer::class,
|
||||
EntityTable::COLUMN_UPDATED_AT => DateTransformer::create(nullable: true),
|
||||
];
|
||||
}
|
||||
|
||||
public function getStatusText(): string
|
||||
{
|
||||
return ucfirst(strtolower($this->status->name));
|
||||
}
|
||||
|
||||
public function validate(): bool
|
||||
{
|
||||
if ($this->isCustom) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (new EntityValidation($this))->isValid();
|
||||
}
|
||||
|
||||
}
|
||||
20
src/Model/Events/EventListener.php
Normal file
20
src/Model/Events/EventListener.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model\Events;
|
||||
|
||||
use Attribute;
|
||||
|
||||
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
|
||||
final readonly class EventListener
|
||||
{
|
||||
/**
|
||||
* Listen to an event.
|
||||
*
|
||||
* @param string $event Event to listen to.
|
||||
*/
|
||||
public function __construct(public string $event)
|
||||
{
|
||||
}
|
||||
}
|
||||
12
src/Model/Events/EventStatus.php
Normal file
12
src/Model/Events/EventStatus.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model\Events;
|
||||
|
||||
enum EventStatus: int
|
||||
{
|
||||
case PENDING = 0;
|
||||
case RUNNING = 1;
|
||||
case SUCCESS = 2;
|
||||
case FAILED = 3;
|
||||
case CANCELLED = 4;
|
||||
}
|
||||
16
src/Model/Events/EventValidation.php
Normal file
16
src/Model/Events/EventValidation.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model\Events;
|
||||
|
||||
use App\Model\Base\BasicValidation;
|
||||
use App\Model\Events\Event as EntityItem;
|
||||
|
||||
final class EventValidation extends BasicValidation
|
||||
{
|
||||
public function __construct(EntityItem $object)
|
||||
{
|
||||
$this->runValidator($object);
|
||||
}
|
||||
}
|
||||
68
src/Model/Events/EventsRepository.php
Normal file
68
src/Model/Events/EventsRepository.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model\Events;
|
||||
|
||||
use App\Model\Base\Traits\UsesBasicRepository;
|
||||
use App\Model\Events\Event as EntityItem;
|
||||
use App\Model\Events\EventsTable as EntityTable;
|
||||
|
||||
final class EventsRepository
|
||||
{
|
||||
use UsesBasicRepository;
|
||||
|
||||
protected string $table = EntityTable::TABLE_NAME;
|
||||
protected string $primaryKey = EntityTable::TABLE_PRIMARY_KEY;
|
||||
|
||||
public function getObject(array $row, bool $isCustom = false, array $opts = []): EntityItem
|
||||
{
|
||||
return (new EntityItem($row, $isCustom, $opts));
|
||||
}
|
||||
|
||||
public function findOne(array $criteria): EntityItem|null
|
||||
{
|
||||
return $this->_findOne($criteria);
|
||||
}
|
||||
|
||||
public function findById(string $id): EntityItem|null
|
||||
{
|
||||
return $this->_findOne([$this->primaryKey => $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $criteria Criteria to search by.
|
||||
* @param array $cols Columns to select.
|
||||
* @return array<EntityItem> empty array if no match found.
|
||||
*/
|
||||
public function findAll(array $criteria = [], array $cols = []): array
|
||||
{
|
||||
return $this->_findAll($criteria, $cols);
|
||||
}
|
||||
|
||||
public function save(EntityItem $object): string
|
||||
{
|
||||
return $this->_save($object, useUUID: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<EntityItem> $items
|
||||
*
|
||||
* @return array<string, EntityItem>
|
||||
*/
|
||||
public function saveAll(array $items): array
|
||||
{
|
||||
return $this->_saveAll($items, useUUID: true);
|
||||
}
|
||||
|
||||
public function remove(EntityItem|array $criteria): bool
|
||||
{
|
||||
return $this->_remove($criteria);
|
||||
}
|
||||
|
||||
public function removeById(string $id): bool
|
||||
{
|
||||
return $this->_removeById($id, $this->primaryKey);
|
||||
}
|
||||
|
||||
}
|
||||
23
src/Model/Events/EventsTable.php
Normal file
23
src/Model/Events/EventsTable.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model\Events;
|
||||
|
||||
use App\Model\Base\BasicModel;
|
||||
|
||||
abstract class EventsTable extends BasicModel
|
||||
{
|
||||
public const string TABLE_NAME = 'events';
|
||||
public const string TABLE_PRIMARY_KEY = self::COLUMN_ID;
|
||||
|
||||
public const string COLUMN_ID = 'id';
|
||||
public const string COLUMN_STATUS = 'status';
|
||||
public const string COLUMN_EVENT = 'event';
|
||||
public const string COLUMN_EVENT_DATA = 'event_data';
|
||||
public const string COLUMN_OPTIONS = 'options';
|
||||
public const string COLUMN_ATTEMPTS = 'attempts';
|
||||
public const string COLUMN_LOGS = 'logs';
|
||||
public const string COLUMN_CREATED_AT = 'created_at';
|
||||
public const string COLUMN_UPDATED_AT = 'updated_at';
|
||||
}
|
||||
Reference in New Issue
Block a user