From 97fee92f63fd8ea52be50e51e321c757b0bafc2c Mon Sep 17 00:00:00 2001 From: Sebastian Meyer Date: Mon, 30 Sep 2024 19:37:29 +0200 Subject: [PATCH] Refactored and cleaned code --- .phpmd.dist.xml | 23 +- CODE_OF_CONDUCT.md | 2 +- CONTRIBUTING.md | 10 +- README.md | 2 +- bin/cli | 2 +- composer.json | 18 +- composer.lock | 738 ++++++++++--------- phpcs.xml.dist | 4 +- phpstan.dist.neon | 7 +- psalm.xml.dist | 31 +- src/App.php | 9 +- src/Configuration.php | 134 +--- src/Console.php | 171 +++-- src/Console/AddRecordCommand.php | 44 +- src/Console/AddSetCommand.php | 37 +- src/Console/CsvImportCommand.php | 134 ++-- src/Console/DeleteRecordCommand.php | 33 +- src/Console/PruneDeletedRecordsCommand.php | 9 +- src/Console/PruneResumptionTokensCommand.php | 7 +- src/Console/UpdateFormatsCommand.php | 72 +- src/Database.php | 573 -------------- src/Entity.php | 56 +- src/Entity/Format.php | 53 +- src/Entity/Record.php | 60 +- src/Entity/Set.php | 49 +- src/Entity/Token.php | 19 +- src/EntityManager.php | 471 ++++++++++++ src/Middleware.php | 107 ++- src/Middleware/Dispatcher.php | 225 ++++-- src/Middleware/ErrorHandler.php | 29 +- src/Middleware/GetRecord.php | 78 +- src/Middleware/Identify.php | 90 ++- src/Middleware/ListIdentifiers.php | 166 ++--- src/Middleware/ListMetadataFormats.php | 54 +- src/Middleware/ListRecords.php | 3 +- src/Middleware/ListSets.php | 94 +-- src/Repository/FormatRepository.php | 72 ++ src/Repository/RecordRepository.php | 103 +++ src/Repository/SetRepository.php | 71 ++ src/Repository/TokenRepository.php | 64 ++ src/{Document.php => Response.php} | 110 +-- src/{Result.php => ResultSet.php} | 44 +- src/Validator/ConfigurationValidator.php | 114 +++ src/Validator/RegExValidator.php | 72 ++ src/Validator/UrlValidator.php | 65 ++ src/Validator/XmlValidator.php | 79 ++ 46 files changed, 2619 insertions(+), 1789 deletions(-) delete mode 100644 src/Database.php create mode 100644 src/EntityManager.php create mode 100644 src/Repository/FormatRepository.php create mode 100644 src/Repository/RecordRepository.php create mode 100644 src/Repository/SetRepository.php create mode 100644 src/Repository/TokenRepository.php rename src/{Document.php => Response.php} (62%) rename src/{Result.php => ResultSet.php} (63%) create mode 100644 src/Validator/ConfigurationValidator.php create mode 100644 src/Validator/RegExValidator.php create mode 100644 src/Validator/UrlValidator.php create mode 100644 src/Validator/XmlValidator.php diff --git a/.phpmd.dist.xml b/.phpmd.dist.xml index 55da42a..59a1afd 100644 --- a/.phpmd.dist.xml +++ b/.phpmd.dist.xml @@ -7,20 +7,35 @@ xsi:noNamespaceSchemaLocation=" http://pmd.sf.net/ruleset_xml_schema.xsd"> - Open Culture Consulting follows PHP Mess Detector standards. + Open Culture Consulting follows PHP Mess Detector standards with few exceptions. + - + - + + + + + + + + + - + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 17bb0a6..b724bf4 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -60,7 +60,7 @@ representative at an online or offline event. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at -office@opencultureconsulting.com. +. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e4b8f8f..830fc30 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,9 @@ -Please read the excellent [GitHub Open Source Guide](https://opensource.guide/how-to-contribute/) on *How to Contribute on Open Source*. +Please read the excellent [GitHub Open Source Guide][guide] on *How to +Contribute on Open Source*. -If you have any further questions just [open a new issue](https://github.com/opencultureconsulting/oai-pmh2/issues/new) and I'll be happy to assist! +[guide]: + +If you have any further questions just [open a new issue][issuetracker] and +I'll be happy to assist! + +[issuetracker]: diff --git a/README.md b/README.md index 3f4e3bd..225d037 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# oai-pmh2 \ No newline at end of file +# oai-pmh2 diff --git a/bin/cli b/bin/cli index e6f3dd3..782b293 100644 --- a/bin/cli +++ b/bin/cli @@ -49,7 +49,7 @@ $commands = [ try { ConsoleRunner::run( new SingleManagerProvider( - Database::getInstance()->getEntityManager() + EntityManager::getInstance() ), $commands ); diff --git a/composer.json b/composer.json index bf45fdc..bd961d9 100644 --- a/composer.json +++ b/composer.json @@ -31,10 +31,10 @@ "ext-dom": "*", "ext-libxml": "*", "ext-sqlite3": "*", - "doctrine/dbal": "^3.8", + "doctrine/dbal": "^4.1", "doctrine/orm": "^3.2", - "opencultureconsulting/basics": "^1.1", - "opencultureconsulting/psr15": "^1.0", + "opencultureconsulting/basics": "^2.1", + "opencultureconsulting/psr15": "^1.2", "symfony/cache": "^6.4", "symfony/console": "^6.4", "symfony/filesystem": "^6.4", @@ -44,11 +44,13 @@ "require-dev": { "phpdocumentor/shim": "^3.5", "phpmd/phpmd": "^2.15", - "phpstan/phpstan": "^1.11", + "phpstan/phpstan": "^1.12", + "phpstan/phpstan-doctrine": "^1.5", "phpstan/phpstan-strict-rules": "^1.6", - "friendsofphp/php-cs-fixer": "^3.59", + "phpstan/phpstan-symfony": "^1.4", + "friendsofphp/php-cs-fixer": "^3.64", "squizlabs/php_codesniffer": "^3.10", - "vimeo/psalm": "^5.25" + "vimeo/psalm": "^5.26" }, "autoload": { "psr-4": { @@ -74,7 +76,7 @@ ], "doctrine:clear-cache": [ "@php bin/cli orm:clear-cache:metadata --flush", - "@php bin/cli orm:clear-cache:query --flush", + "@php bin/cli orm:clear-cache:query", "@php bin/cli orm:clear-cache:result --flush" ], "doctrine:initialize-database": [ @@ -95,7 +97,7 @@ ], "phpmd:check": [ "@php -r \"if (!file_exists('./.phpmd.xml')) { copy('./.phpmd.dist.xml', './.phpmd.xml'); }\"", - "@php vendor/bin/phpmd ./bin,./public,./src ansi .phpmd.xml --cache" + "@php vendor/bin/phpmd bin/,public/,src/ ansi .phpmd.xml --cache" ], "phpstan:check": [ "@php vendor/bin/phpstan" diff --git a/composer.lock b/composer.lock index 6356807..991f815 100644 --- a/composer.lock +++ b/composer.lock @@ -4,101 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "176c33af04203780cfc99ddb9b4742f6", + "content-hash": "511dc23587fc62a89af37b88169e867a", "packages": [ - { - "name": "doctrine/cache", - "version": "2.2.0", - "source": { - "type": "git", - "url": "https://github.com/doctrine/cache.git", - "reference": "1ca8f21980e770095a31456042471a57bc4c68fb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/cache/zipball/1ca8f21980e770095a31456042471a57bc4c68fb", - "reference": "1ca8f21980e770095a31456042471a57bc4c68fb", - "shasum": "" - }, - "require": { - "php": "~7.1 || ^8.0" - }, - "conflict": { - "doctrine/common": ">2.2,<2.4" - }, - "require-dev": { - "cache/integration-tests": "dev-master", - "doctrine/coding-standard": "^9", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "psr/cache": "^1.0 || ^2.0 || ^3.0", - "symfony/cache": "^4.4 || ^5.4 || ^6", - "symfony/var-exporter": "^4.4 || ^5.4 || ^6" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Common\\Cache\\": "lib/Doctrine/Common/Cache" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de" - }, - { - "name": "Jonathan Wage", - "email": "jonwage@gmail.com" - }, - { - "name": "Johannes Schmitt", - "email": "schmittjoh@gmail.com" - } - ], - "description": "PHP Doctrine Cache library is a popular cache implementation that supports many different drivers such as redis, memcache, apc, mongodb and others.", - "homepage": "https://www.doctrine-project.org/projects/cache.html", - "keywords": [ - "abstraction", - "apcu", - "cache", - "caching", - "couchdb", - "memcached", - "php", - "redis", - "xcache" - ], - "support": { - "issues": "https://github.com/doctrine/cache/issues", - "source": "https://github.com/doctrine/cache/tree/2.2.0" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcache", - "type": "tidelift" - } - ], - "time": "2022-05-20T20:07:39+00:00" - }, { "name": "doctrine/collections", "version": "2.2.2", @@ -187,47 +94,42 @@ }, { "name": "doctrine/dbal", - "version": "3.8.7", + "version": "4.1.1", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "2093d670ca17f634f3c095ec10a20687eccebd99" + "reference": "7a8252418689feb860ea8dfeab66d64a56a64df8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/2093d670ca17f634f3c095ec10a20687eccebd99", - "reference": "2093d670ca17f634f3c095ec10a20687eccebd99", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/7a8252418689feb860ea8dfeab66d64a56a64df8", + "reference": "7a8252418689feb860ea8dfeab66d64a56a64df8", "shasum": "" }, "require": { - "composer-runtime-api": "^2", - "doctrine/cache": "^1.11|^2.0", "doctrine/deprecations": "^0.5.3|^1", - "doctrine/event-manager": "^1|^2", - "php": "^7.4 || ^8.0", + "php": "^8.1", "psr/cache": "^1|^2|^3", "psr/log": "^1|^2|^3" }, "require-dev": { "doctrine/coding-standard": "12.0.0", "fig/log-test": "^1", - "jetbrains/phpstorm-stubs": "2023.1", - "phpstan/phpstan": "1.11.7", + "jetbrains/phpstorm-stubs": "2023.2", + "phpstan/phpstan": "1.12.0", + "phpstan/phpstan-phpunit": "1.4.0", "phpstan/phpstan-strict-rules": "^1.6", - "phpunit/phpunit": "9.6.20", - "psalm/plugin-phpunit": "0.18.4", + "phpunit/phpunit": "10.5.30", + "psalm/plugin-phpunit": "0.19.0", "slevomat/coding-standard": "8.13.1", "squizlabs/php_codesniffer": "3.10.2", - "symfony/cache": "^5.4|^6.0|^7.0", - "symfony/console": "^4.4|^5.4|^6.0|^7.0", - "vimeo/psalm": "4.30.0" + "symfony/cache": "^6.3.8|^7.0", + "symfony/console": "^5.4|^6.3|^7.0", + "vimeo/psalm": "5.25.0" }, "suggest": { "symfony/console": "For helpful console commands such as SQL execution and import of files." }, - "bin": [ - "bin/doctrine-dbal" - ], "type": "library", "autoload": { "psr-4": { @@ -280,7 +182,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.8.7" + "source": "https://github.com/doctrine/dbal/tree/4.1.1" }, "funding": [ { @@ -296,7 +198,7 @@ "type": "tidelift" } ], - "time": "2024-08-07T11:57:25+00:00" + "time": "2024-09-03T08:58:39+00:00" }, { "name": "doctrine/deprecations", @@ -676,16 +578,16 @@ }, { "name": "doctrine/orm", - "version": "3.2.1", + "version": "3.2.2", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "722cea6536775206e81744542b36fa7c9a4ea3e5" + "reference": "831a1eb7d260925528cdbb49cc1866c0357cf147" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/722cea6536775206e81744542b36fa7c9a4ea3e5", - "reference": "722cea6536775206e81744542b36fa7c9a4ea3e5", + "url": "https://api.github.com/repos/doctrine/orm/zipball/831a1eb7d260925528cdbb49cc1866c0357cf147", + "reference": "831a1eb7d260925528cdbb49cc1866c0357cf147", "shasum": "" }, "require": { @@ -758,9 +660,9 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/3.2.1" + "source": "https://github.com/doctrine/orm/tree/3.2.2" }, - "time": "2024-06-26T21:48:58+00:00" + "time": "2024-08-23T10:03:52+00:00" }, { "name": "doctrine/persistence", @@ -977,25 +879,29 @@ }, { "name": "opencultureconsulting/basics", - "version": "v1.1.0", + "version": "v2.1.2", "source": { "type": "git", "url": "https://github.com/opencultureconsulting/php-basics.git", - "reference": "f832b58a3e18b7c142401d2d3e0ce505ea6c2a6b" + "reference": "6abfd3b050344dde4f8ba12e07cac355ff3ab223" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opencultureconsulting/php-basics/zipball/f832b58a3e18b7c142401d2d3e0ce505ea6c2a6b", - "reference": "f832b58a3e18b7c142401d2d3e0ce505ea6c2a6b", + "url": "https://api.github.com/repos/opencultureconsulting/php-basics/zipball/6abfd3b050344dde4f8ba12e07cac355ff3ab223", + "reference": "6abfd3b050344dde4f8ba12e07cac355ff3ab223", "shasum": "" }, "require": { - "php": "^8.0" + "php": "^8.1" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.48", - "phpstan/phpstan": "^1.10.56", - "phpstan/phpstan-strict-rules": "^1.5" + "friendsofphp/php-cs-fixer": "^3.64", + "phpdocumentor/shim": "^3.5", + "phpmd/phpmd": "^2.15", + "phpstan/phpstan": "^1.12", + "phpstan/phpstan-strict-rules": "^1.6", + "squizlabs/php_codesniffer": "^3.10", + "vimeo/psalm": "^5.25" }, "type": "library", "autoload": { @@ -1015,23 +921,30 @@ "role": "maintainer" } ], - "description": "This is a collection of generic Classes and useful Traits for PHP projects.", - "homepage": "https://github.com/opencultureconsulting/php-basics", + "description": "A collection of generic classes and useful traits for PHP projects.", + "homepage": "https://opencultureconsulting.github.io/php-basics/", "keywords": [ "ArrayAccess", "IteratorAggregate", + "OverloadingGetter", + "OverloadingSetter", + "StrictArray", + "StrictCollection", "StrictList", "StrictQueue", "StrictStack", + "TriggerExceptionError", "countable", "getter", "iterator", + "overloading", "setter", "singleton", - "throwErrorException" + "throwErrorException", + "typechecker" ], "support": { - "docs": "https://github.com/opencultureconsulting/php-basics/blob/main/README.md", + "docs": "https://opencultureconsulting.github.io/php-basics/", "issues": "https://github.com/opencultureconsulting/php-basics/issues", "source": "https://github.com/opencultureconsulting/php-basics" }, @@ -1045,26 +958,26 @@ "type": "github" } ], - "time": "2024-01-22T20:03:54+00:00" + "time": "2024-09-07T19:31:09+00:00" }, { "name": "opencultureconsulting/psr15", - "version": "v1.0.1", + "version": "v1.2.0", "source": { "type": "git", "url": "https://github.com/opencultureconsulting/psr-15.git", - "reference": "e473341f46ecf021e3e729a9fa5de0efa6f7068e" + "reference": "8b51cfccb4d9aa364eec1971b4c554c9236ded00" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opencultureconsulting/psr-15/zipball/e473341f46ecf021e3e729a9fa5de0efa6f7068e", - "reference": "e473341f46ecf021e3e729a9fa5de0efa6f7068e", + "url": "https://api.github.com/repos/opencultureconsulting/psr-15/zipball/8b51cfccb4d9aa364eec1971b4c554c9236ded00", + "reference": "8b51cfccb4d9aa364eec1971b4c554c9236ded00", "shasum": "" }, "require": { - "guzzlehttp/psr7": "^2.6", - "opencultureconsulting/basics": "^1.1", - "php": "^8.0", + "guzzlehttp/psr7": "^2.7", + "opencultureconsulting/basics": "^2.1", + "php": "^8.1", "psr/http-server-handler": "^1.0", "psr/http-server-middleware": "^1.0" }, @@ -1073,9 +986,13 @@ "psr/http-server-middleware-implementation": "1.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.48", - "phpstan/phpstan": "^1.10", - "phpstan/phpstan-strict-rules": "^1.5" + "friendsofphp/php-cs-fixer": "^3.64", + "phpdocumentor/shim": "^3.5", + "phpmd/phpmd": "^2.15", + "phpstan/phpstan": "^1.12", + "phpstan/phpstan-strict-rules": "^1.6", + "squizlabs/php_codesniffer": "^3.10", + "vimeo/psalm": "^5.25" }, "type": "library", "autoload": { @@ -1104,10 +1021,11 @@ "http-server-middleware", "middleware", "psr-15", + "queue", "request" ], "support": { - "docs": "https://github.com/opencultureconsulting/psr-15/blob/main/README.md", + "docs": "https://opencultureconsulting.github.io/psr-15/", "issues": "https://github.com/opencultureconsulting/psr-15/issues", "source": "https://github.com/opencultureconsulting/psr-15" }, @@ -1121,7 +1039,7 @@ "type": "github" } ], - "time": "2024-01-22T20:13:28+00:00" + "time": "2024-09-07T17:45:23+00:00" }, { "name": "psr/cache", @@ -1448,16 +1366,16 @@ }, { "name": "psr/log", - "version": "3.0.0", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001" + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/fe5ea303b0887d5caefd3d431c3e61ad47037001", - "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", "shasum": "" }, "require": { @@ -1492,9 +1410,9 @@ "psr-3" ], "support": { - "source": "https://github.com/php-fig/log/tree/3.0.0" + "source": "https://github.com/php-fig/log/tree/3.0.2" }, - "time": "2021-07-14T16:46:02+00:00" + "time": "2024-09-11T13:17:53+00:00" }, { "name": "ralouphie/getallheaders", @@ -1542,16 +1460,16 @@ }, { "name": "symfony/cache", - "version": "v6.4.10", + "version": "v6.4.12", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "6702d2d777260e6ff3451fee2d7d78ab5f715cdc" + "reference": "a463451b7f6ac4a47b98dbfc78ec2d3560c759d8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/6702d2d777260e6ff3451fee2d7d78ab5f715cdc", - "reference": "6702d2d777260e6ff3451fee2d7d78ab5f715cdc", + "url": "https://api.github.com/repos/symfony/cache/zipball/a463451b7f6ac4a47b98dbfc78ec2d3560c759d8", + "reference": "a463451b7f6ac4a47b98dbfc78ec2d3560c759d8", "shasum": "" }, "require": { @@ -1618,7 +1536,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v6.4.10" + "source": "https://github.com/symfony/cache/tree/v6.4.12" }, "funding": [ { @@ -1634,7 +1552,7 @@ "type": "tidelift" } ], - "time": "2024-07-17T06:05:49+00:00" + "time": "2024-09-16T16:01:33+00:00" }, { "name": "symfony/cache-contracts", @@ -1714,16 +1632,16 @@ }, { "name": "symfony/console", - "version": "v6.4.10", + "version": "v6.4.12", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "504974cbe43d05f83b201d6498c206f16fc0cdbc" + "reference": "72d080eb9edf80e36c19be61f72c98ed8273b765" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/504974cbe43d05f83b201d6498c206f16fc0cdbc", - "reference": "504974cbe43d05f83b201d6498c206f16fc0cdbc", + "url": "https://api.github.com/repos/symfony/console/zipball/72d080eb9edf80e36c19be61f72c98ed8273b765", + "reference": "72d080eb9edf80e36c19be61f72c98ed8273b765", "shasum": "" }, "require": { @@ -1788,7 +1706,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.10" + "source": "https://github.com/symfony/console/tree/v6.4.12" }, "funding": [ { @@ -1804,7 +1722,7 @@ "type": "tidelift" } ], - "time": "2024-07-26T12:30:32+00:00" + "time": "2024-09-20T08:15:52+00:00" }, { "name": "symfony/deprecation-contracts", @@ -1875,16 +1793,16 @@ }, { "name": "symfony/filesystem", - "version": "v6.4.9", + "version": "v6.4.12", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "b51ef8059159330b74a4d52f68e671033c0fe463" + "reference": "f810e3cbdf7fdc35983968523d09f349fa9ada12" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/b51ef8059159330b74a4d52f68e671033c0fe463", - "reference": "b51ef8059159330b74a4d52f68e671033c0fe463", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/f810e3cbdf7fdc35983968523d09f349fa9ada12", + "reference": "f810e3cbdf7fdc35983968523d09f349fa9ada12", "shasum": "" }, "require": { @@ -1921,7 +1839,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.9" + "source": "https://github.com/symfony/filesystem/tree/v6.4.12" }, "funding": [ { @@ -1937,24 +1855,24 @@ "type": "tidelift" } ], - "time": "2024-06-28T09:49:33+00:00" + "time": "2024-09-16T16:01:33+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "0424dff1c58f028c451efff2045f5d92410bd540" + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/0424dff1c58f028c451efff2045f5d92410bd540", - "reference": "0424dff1c58f028c451efff2045f5d92410bd540", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "provide": { "ext-ctype": "*" @@ -2000,7 +1918,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" }, "funding": [ { @@ -2016,24 +1934,24 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "64647a7c30b2283f5d49b874d84a18fc22054b7a" + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/64647a7c30b2283f5d49b874d84a18fc22054b7a", - "reference": "64647a7c30b2283f5d49b874d84a18fc22054b7a", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "suggest": { "ext-intl": "For best performance" @@ -2078,7 +1996,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" }, "funding": [ { @@ -2094,24 +2012,24 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "a95281b0be0d9ab48050ebd988b967875cdb9fdb" + "reference": "3833d7255cc303546435cb650316bff708a1c75c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/a95281b0be0d9ab48050ebd988b967875cdb9fdb", - "reference": "a95281b0be0d9ab48050ebd988b967875cdb9fdb", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "suggest": { "ext-intl": "For best performance" @@ -2159,7 +2077,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" }, "funding": [ { @@ -2175,24 +2093,24 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "fd22ab50000ef01661e2a31d850ebaa297f8e03c" + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fd22ab50000ef01661e2a31d850ebaa297f8e03c", - "reference": "fd22ab50000ef01661e2a31d850ebaa297f8e03c", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "provide": { "ext-mbstring": "*" @@ -2239,7 +2157,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" }, "funding": [ { @@ -2255,24 +2173,24 @@ "type": "tidelift" } ], - "time": "2024-06-19T12:30:46+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "dbdcdf1a4dcc2743591f1079d0c35ab1e2dcbbc9" + "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/dbdcdf1a4dcc2743591f1079d0c35ab1e2dcbbc9", - "reference": "dbdcdf1a4dcc2743591f1079d0c35ab1e2dcbbc9", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/2fb86d65e2d424369ad2905e83b236a8805ba491", + "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "type": "library", "extra": { @@ -2315,7 +2233,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.31.0" }, "funding": [ { @@ -2331,7 +2249,7 @@ "type": "tidelift" } ], - "time": "2024-06-19T12:35:24+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/service-contracts", @@ -2418,16 +2336,16 @@ }, { "name": "symfony/string", - "version": "v6.4.10", + "version": "v6.4.12", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "ccf9b30251719567bfd46494138327522b9a9446" + "reference": "f8a1ccebd0997e16112dfecfd74220b78e5b284b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/ccf9b30251719567bfd46494138327522b9a9446", - "reference": "ccf9b30251719567bfd46494138327522b9a9446", + "url": "https://api.github.com/repos/symfony/string/zipball/f8a1ccebd0997e16112dfecfd74220b78e5b284b", + "reference": "f8a1ccebd0997e16112dfecfd74220b78e5b284b", "shasum": "" }, "require": { @@ -2484,7 +2402,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.10" + "source": "https://github.com/symfony/string/tree/v6.4.12" }, "funding": [ { @@ -2500,7 +2418,7 @@ "type": "tidelift" } ], - "time": "2024-07-22T10:21:14+00:00" + "time": "2024-09-20T08:15:52+00:00" }, { "name": "symfony/translation-contracts", @@ -2582,16 +2500,16 @@ }, { "name": "symfony/validator", - "version": "v6.4.10", + "version": "v6.4.12", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "bcf939a9d1acd7d2912e9474c0c3d7840a03cbcd" + "reference": "6da1f0a1ee73d060a411d832cbe0539cfe9bbaa0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/bcf939a9d1acd7d2912e9474c0c3d7840a03cbcd", - "reference": "bcf939a9d1acd7d2912e9474c0c3d7840a03cbcd", + "url": "https://api.github.com/repos/symfony/validator/zipball/6da1f0a1ee73d060a411d832cbe0539cfe9bbaa0", + "reference": "6da1f0a1ee73d060a411d832cbe0539cfe9bbaa0", "shasum": "" }, "require": { @@ -2659,7 +2577,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v6.4.10" + "source": "https://github.com/symfony/validator/tree/v6.4.12" }, "funding": [ { @@ -2675,7 +2593,7 @@ "type": "tidelift" } ], - "time": "2024-07-26T12:30:32+00:00" + "time": "2024-09-20T08:18:25+00:00" }, { "name": "symfony/var-exporter", @@ -2756,16 +2674,16 @@ }, { "name": "symfony/yaml", - "version": "v6.4.8", + "version": "v6.4.12", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "52903de178d542850f6f341ba92995d3d63e60c9" + "reference": "762ee56b2649659380e0ef4d592d807bc17b7971" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/52903de178d542850f6f341ba92995d3d63e60c9", - "reference": "52903de178d542850f6f341ba92995d3d63e60c9", + "url": "https://api.github.com/repos/symfony/yaml/zipball/762ee56b2649659380e0ef4d592d807bc17b7971", + "reference": "762ee56b2649659380e0ef4d592d807bc17b7971", "shasum": "" }, "require": { @@ -2808,7 +2726,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v6.4.8" + "source": "https://github.com/symfony/yaml/tree/v6.4.12" }, "funding": [ { @@ -2824,7 +2742,7 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:49:08+00:00" + "time": "2024-09-17T12:47:12+00:00" } ], "packages-dev": [ @@ -3054,26 +2972,26 @@ }, { "name": "composer/pcre", - "version": "3.2.0", + "version": "3.3.1", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "ea4ab6f9580a4fd221e0418f2c357cdd39102a90" + "reference": "63aaeac21d7e775ff9bc9d45021e1745c97521c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/ea4ab6f9580a4fd221e0418f2c357cdd39102a90", - "reference": "ea4ab6f9580a4fd221e0418f2c357cdd39102a90", + "url": "https://api.github.com/repos/composer/pcre/zipball/63aaeac21d7e775ff9bc9d45021e1745c97521c4", + "reference": "63aaeac21d7e775ff9bc9d45021e1745c97521c4", "shasum": "" }, "require": { "php": "^7.4 || ^8.0" }, "conflict": { - "phpstan/phpstan": "<1.11.8" + "phpstan/phpstan": "<1.11.10" }, "require-dev": { - "phpstan/phpstan": "^1.11.8", + "phpstan/phpstan": "^1.11.10", "phpstan/phpstan-strict-rules": "^1.1", "phpunit/phpunit": "^8 || ^9" }, @@ -3113,7 +3031,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.2.0" + "source": "https://github.com/composer/pcre/tree/3.3.1" }, "funding": [ { @@ -3129,28 +3047,28 @@ "type": "tidelift" } ], - "time": "2024-07-25T09:36:02+00:00" + "time": "2024-08-27T18:44:43+00:00" }, { "name": "composer/semver", - "version": "3.4.2", + "version": "3.4.3", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "c51258e759afdb17f1fd1fe83bc12baaef6309d6" + "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/c51258e759afdb17f1fd1fe83bc12baaef6309d6", - "reference": "c51258e759afdb17f1fd1fe83bc12baaef6309d6", + "url": "https://api.github.com/repos/composer/semver/zipball/4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", + "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", "shasum": "" }, "require": { "php": "^5.3.2 || ^7.0 || ^8.0" }, "require-dev": { - "phpstan/phpstan": "^1.4", - "symfony/phpunit-bridge": "^4.2 || ^5" + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" }, "type": "library", "extra": { @@ -3194,7 +3112,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.2" + "source": "https://github.com/composer/semver/tree/3.4.3" }, "funding": [ { @@ -3210,7 +3128,7 @@ "type": "tidelift" } ], - "time": "2024-07-12T11:35:52+00:00" + "time": "2024-09-19T14:15:21+00:00" }, { "name": "composer/xdebug-handler", @@ -3409,16 +3327,16 @@ }, { "name": "felixfbecker/language-server-protocol", - "version": "v1.5.2", + "version": "v1.5.3", "source": { "type": "git", "url": "https://github.com/felixfbecker/php-language-server-protocol.git", - "reference": "6e82196ffd7c62f7794d778ca52b69feec9f2842" + "reference": "a9e113dbc7d849e35b8776da39edaf4313b7b6c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/felixfbecker/php-language-server-protocol/zipball/6e82196ffd7c62f7794d778ca52b69feec9f2842", - "reference": "6e82196ffd7c62f7794d778ca52b69feec9f2842", + "url": "https://api.github.com/repos/felixfbecker/php-language-server-protocol/zipball/a9e113dbc7d849e35b8776da39edaf4313b7b6c9", + "reference": "a9e113dbc7d849e35b8776da39edaf4313b7b6c9", "shasum": "" }, "require": { @@ -3459,22 +3377,22 @@ ], "support": { "issues": "https://github.com/felixfbecker/php-language-server-protocol/issues", - "source": "https://github.com/felixfbecker/php-language-server-protocol/tree/v1.5.2" + "source": "https://github.com/felixfbecker/php-language-server-protocol/tree/v1.5.3" }, - "time": "2022-03-02T22:36:06+00:00" + "time": "2024-04-30T00:40:11+00:00" }, { "name": "fidry/cpu-core-counter", - "version": "1.1.0", + "version": "1.2.0", "source": { "type": "git", "url": "https://github.com/theofidry/cpu-core-counter.git", - "reference": "f92996c4d5c1a696a6a970e20f7c4216200fcc42" + "reference": "8520451a140d3f46ac33042715115e290cf5785f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/f92996c4d5c1a696a6a970e20f7c4216200fcc42", - "reference": "f92996c4d5c1a696a6a970e20f7c4216200fcc42", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/8520451a140d3f46ac33042715115e290cf5785f", + "reference": "8520451a140d3f46ac33042715115e290cf5785f", "shasum": "" }, "require": { @@ -3514,7 +3432,7 @@ ], "support": { "issues": "https://github.com/theofidry/cpu-core-counter/issues", - "source": "https://github.com/theofidry/cpu-core-counter/tree/1.1.0" + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.2.0" }, "funding": [ { @@ -3522,20 +3440,20 @@ "type": "github" } ], - "time": "2024-02-07T09:43:46+00:00" + "time": "2024-08-06T10:04:20+00:00" }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.62.0", + "version": "v3.64.0", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "627692f794d35c43483f34b01d94740df2a73507" + "reference": "58dd9c931c785a79739310aef5178928305ffa67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/627692f794d35c43483f34b01d94740df2a73507", - "reference": "627692f794d35c43483f34b01d94740df2a73507", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/58dd9c931c785a79739310aef5178928305ffa67", + "reference": "58dd9c931c785a79739310aef5178928305ffa67", "shasum": "" }, "require": { @@ -3617,7 +3535,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.62.0" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.64.0" }, "funding": [ { @@ -3625,20 +3543,20 @@ "type": "github" } ], - "time": "2024-08-07T17:03:09+00:00" + "time": "2024-08-30T23:09:38+00:00" }, { "name": "netresearch/jsonmapper", - "version": "v4.4.1", + "version": "v4.5.0", "source": { "type": "git", "url": "https://github.com/cweiske/jsonmapper.git", - "reference": "132c75c7dd83e45353ebb9c6c9f591952995bbf0" + "reference": "8e76efb98ee8b6afc54687045e1b8dba55ac76e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cweiske/jsonmapper/zipball/132c75c7dd83e45353ebb9c6c9f591952995bbf0", - "reference": "132c75c7dd83e45353ebb9c6c9f591952995bbf0", + "url": "https://api.github.com/repos/cweiske/jsonmapper/zipball/8e76efb98ee8b6afc54687045e1b8dba55ac76e5", + "reference": "8e76efb98ee8b6afc54687045e1b8dba55ac76e5", "shasum": "" }, "require": { @@ -3674,22 +3592,22 @@ "support": { "email": "cweiske@cweiske.de", "issues": "https://github.com/cweiske/jsonmapper/issues", - "source": "https://github.com/cweiske/jsonmapper/tree/v4.4.1" + "source": "https://github.com/cweiske/jsonmapper/tree/v4.5.0" }, - "time": "2024-01-31T06:18:54+00:00" + "time": "2024-09-08T10:13:13+00:00" }, { "name": "nikic/php-parser", - "version": "v4.19.1", + "version": "v4.19.4", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "4e1b88d21c69391150ace211e9eaf05810858d0b" + "reference": "715f4d25e225bc47b293a8b997fe6ce99bf987d2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/4e1b88d21c69391150ace211e9eaf05810858d0b", - "reference": "4e1b88d21c69391150ace211e9eaf05810858d0b", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/715f4d25e225bc47b293a8b997fe6ce99bf987d2", + "reference": "715f4d25e225bc47b293a8b997fe6ce99bf987d2", "shasum": "" }, "require": { @@ -3698,7 +3616,7 @@ }, "require-dev": { "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" }, "bin": [ "bin/php-parse" @@ -3730,9 +3648,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.19.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v4.19.4" }, - "time": "2024-03-17T08:10:35+00:00" + "time": "2024-09-29T15:01:53+00:00" }, { "name": "pdepend/pdepend", @@ -3965,16 +3883,16 @@ }, { "name": "phar-io/gnupg", - "version": "1.0.2", + "version": "1.0.3", "source": { "type": "git", "url": "https://github.com/phar-io/gnupg.git", - "reference": "3c106d39f62ba3941f830ca24e125cb1b9290a87" + "reference": "ed8ab1740ac4e9db99500e7252911f2821357093" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/gnupg/zipball/3c106d39f62ba3941f830ca24e125cb1b9290a87", - "reference": "3c106d39f62ba3941f830ca24e125cb1b9290a87", + "url": "https://api.github.com/repos/phar-io/gnupg/zipball/ed8ab1740ac4e9db99500e7252911f2821357093", + "reference": "ed8ab1740ac4e9db99500e7252911f2821357093", "shasum": "" }, "require": { @@ -4007,9 +3925,9 @@ "description": "Thin GnuPG wrapper class around the gnupg binary, mimicking the pecl/gnupg api", "support": { "issues": "https://github.com/phar-io/gnupg/issues", - "source": "https://github.com/phar-io/gnupg/tree/1.0.2" + "source": "https://github.com/phar-io/gnupg/tree/1.0.3" }, - "time": "2020-11-30T10:21:26+00:00" + "time": "2024-08-22T20:45:57+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -4307,16 +4225,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "1.29.1", + "version": "1.32.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "fcaefacf2d5c417e928405b71b400d4ce10daaf4" + "reference": "6ca22b154efdd9e3c68c56f5d94670920a1c19a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fcaefacf2d5c417e928405b71b400d4ce10daaf4", - "reference": "fcaefacf2d5c417e928405b71b400d4ce10daaf4", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/6ca22b154efdd9e3c68c56f5d94670920a1c19a4", + "reference": "6ca22b154efdd9e3c68c56f5d94670920a1c19a4", "shasum": "" }, "require": { @@ -4348,22 +4266,22 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.29.1" + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.32.0" }, - "time": "2024-05-31T08:52:43+00:00" + "time": "2024-09-26T07:23:32+00:00" }, { "name": "phpstan/phpstan", - "version": "1.11.10", + "version": "1.12.5", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "640410b32995914bde3eed26fa89552f9c2c082f" + "reference": "7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/640410b32995914bde3eed26fa89552f9c2c082f", - "reference": "640410b32995914bde3eed26fa89552f9c2c082f", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17", + "reference": "7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17", "shasum": "" }, "require": { @@ -4408,25 +4326,97 @@ "type": "github" } ], - "time": "2024-08-08T09:02:50+00:00" + "time": "2024-09-26T12:45:22+00:00" }, { - "name": "phpstan/phpstan-strict-rules", - "version": "1.6.0", + "name": "phpstan/phpstan-doctrine", + "version": "1.5.3", "source": { "type": "git", - "url": "https://github.com/phpstan/phpstan-strict-rules.git", - "reference": "363f921dd8441777d4fc137deb99beb486c77df1" + "url": "https://github.com/phpstan/phpstan-doctrine.git", + "reference": "38db3bad8f1567d7bf64806738d724261f8a2b5c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/363f921dd8441777d4fc137deb99beb486c77df1", - "reference": "363f921dd8441777d4fc137deb99beb486c77df1", + "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/38db3bad8f1567d7bf64806738d724261f8a2b5c", + "reference": "38db3bad8f1567d7bf64806738d724261f8a2b5c", "shasum": "" }, "require": { "php": "^7.2 || ^8.0", - "phpstan/phpstan": "^1.11" + "phpstan/phpstan": "^1.11.7" + }, + "conflict": { + "doctrine/collections": "<1.0", + "doctrine/common": "<2.7", + "doctrine/mongodb-odm": "<1.2", + "doctrine/orm": "<2.5", + "doctrine/persistence": "<1.3" + }, + "require-dev": { + "cache/array-adapter": "^1.1", + "composer/semver": "^3.3.2", + "cweagans/composer-patches": "^1.7.3", + "doctrine/annotations": "^1.11 || ^2.0", + "doctrine/collections": "^1.6 || ^2.1", + "doctrine/common": "^2.7 || ^3.0", + "doctrine/dbal": "^2.13.8 || ^3.3.3", + "doctrine/lexer": "^2.0 || ^3.0", + "doctrine/mongodb-odm": "^1.3 || ^2.4.3", + "doctrine/orm": "^2.16.0", + "doctrine/persistence": "^2.2.1 || ^3.2", + "gedmo/doctrine-extensions": "^3.8", + "nesbot/carbon": "^2.49", + "nikic/php-parser": "^4.13.2", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-phpunit": "^1.3.13", + "phpstan/phpstan-strict-rules": "^1.5.1", + "phpunit/phpunit": "^9.6.16", + "ramsey/uuid": "^4.2", + "symfony/cache": "^5.4" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Doctrine extensions for PHPStan", + "support": { + "issues": "https://github.com/phpstan/phpstan-doctrine/issues", + "source": "https://github.com/phpstan/phpstan-doctrine/tree/1.5.3" + }, + "time": "2024-09-01T13:17:34+00:00" + }, + { + "name": "phpstan/phpstan-strict-rules", + "version": "1.6.1", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-strict-rules.git", + "reference": "daeec748b53de80a97498462513066834ec28f8b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/daeec748b53de80a97498462513066834ec28f8b", + "reference": "daeec748b53de80a97498462513066834ec28f8b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "phpstan/phpstan": "^1.12.4" }, "require-dev": { "nikic/php-parser": "^4.13.0", @@ -4455,9 +4445,81 @@ "description": "Extra strict and opinionated rules for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", - "source": "https://github.com/phpstan/phpstan-strict-rules/tree/1.6.0" + "source": "https://github.com/phpstan/phpstan-strict-rules/tree/1.6.1" }, - "time": "2024-04-20T06:37:51+00:00" + "time": "2024-09-20T14:04:44+00:00" + }, + { + "name": "phpstan/phpstan-symfony", + "version": "1.4.10", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-symfony.git", + "reference": "f7d5782044bedf93aeb3f38e09c91148ee90e5a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/f7d5782044bedf93aeb3f38e09c91148ee90e5a1", + "reference": "f7d5782044bedf93aeb3f38e09c91148ee90e5a1", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "php": "^7.2 || ^8.0", + "phpstan/phpstan": "^1.12" + }, + "conflict": { + "symfony/framework-bundle": "<3.0" + }, + "require-dev": { + "nikic/php-parser": "^4.13.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-phpunit": "^1.3.11", + "phpstan/phpstan-strict-rules": "^1.5.1", + "phpunit/phpunit": "^8.5.29 || ^9.5", + "psr/container": "1.0 || 1.1.1", + "symfony/config": "^5.4 || ^6.1", + "symfony/console": "^5.4 || ^6.1", + "symfony/dependency-injection": "^5.4 || ^6.1", + "symfony/form": "^5.4 || ^6.1", + "symfony/framework-bundle": "^5.4 || ^6.1", + "symfony/http-foundation": "^5.4 || ^6.1", + "symfony/messenger": "^5.4", + "symfony/polyfill-php80": "^1.24", + "symfony/serializer": "^5.4", + "symfony/service-contracts": "^2.2.0" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lukáš Unger", + "email": "looky.msc@gmail.com", + "homepage": "https://lookyman.net" + } + ], + "description": "Symfony Framework extensions and rules for PHPStan", + "support": { + "issues": "https://github.com/phpstan/phpstan-symfony/issues", + "source": "https://github.com/phpstan/phpstan-symfony/tree/1.4.10" + }, + "time": "2024-09-26T18:14:50+00:00" }, { "name": "psr/event-dispatcher", @@ -5176,16 +5238,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.10.2", + "version": "3.10.3", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "86e5f5dd9a840c46810ebe5ff1885581c42a3017" + "reference": "62d32998e820bddc40f99f8251958aed187a5c9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/86e5f5dd9a840c46810ebe5ff1885581c42a3017", - "reference": "86e5f5dd9a840c46810ebe5ff1885581c42a3017", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/62d32998e820bddc40f99f8251958aed187a5c9c", + "reference": "62d32998e820bddc40f99f8251958aed187a5c9c", "shasum": "" }, "require": { @@ -5252,7 +5314,7 @@ "type": "open_collective" } ], - "time": "2024-07-21T23:26:44+00:00" + "time": "2024-09-18T10:38:58+00:00" }, { "name": "symfony/config", @@ -5331,16 +5393,16 @@ }, { "name": "symfony/dependency-injection", - "version": "v6.4.10", + "version": "v6.4.12", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "5caf9c5f6085f13b27d70a236b776c07e4a1c3eb" + "reference": "cfb9d34a1cdd4911bc737a5358fd1cf8ebfb536e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/5caf9c5f6085f13b27d70a236b776c07e4a1c3eb", - "reference": "5caf9c5f6085f13b27d70a236b776c07e4a1c3eb", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/cfb9d34a1cdd4911bc737a5358fd1cf8ebfb536e", + "reference": "cfb9d34a1cdd4911bc737a5358fd1cf8ebfb536e", "shasum": "" }, "require": { @@ -5392,7 +5454,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v6.4.10" + "source": "https://github.com/symfony/dependency-injection/tree/v6.4.12" }, "funding": [ { @@ -5408,7 +5470,7 @@ "type": "tidelift" } ], - "time": "2024-07-26T07:32:07+00:00" + "time": "2024-09-20T08:18:25+00:00" }, { "name": "symfony/event-dispatcher", @@ -5568,16 +5630,16 @@ }, { "name": "symfony/finder", - "version": "v6.4.10", + "version": "v6.4.11", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "af29198d87112bebdd397bd7735fbd115997824c" + "reference": "d7eb6daf8cd7e9ac4976e9576b32042ef7253453" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/af29198d87112bebdd397bd7735fbd115997824c", - "reference": "af29198d87112bebdd397bd7735fbd115997824c", + "url": "https://api.github.com/repos/symfony/finder/zipball/d7eb6daf8cd7e9ac4976e9576b32042ef7253453", + "reference": "d7eb6daf8cd7e9ac4976e9576b32042ef7253453", "shasum": "" }, "require": { @@ -5612,7 +5674,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.4.10" + "source": "https://github.com/symfony/finder/tree/v6.4.11" }, "funding": [ { @@ -5628,7 +5690,7 @@ "type": "tidelift" } ], - "time": "2024-07-24T07:06:38+00:00" + "time": "2024-08-13T14:27:37+00:00" }, { "name": "symfony/options-resolver", @@ -5699,20 +5761,20 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "77fa7995ac1b21ab60769b7323d600a991a90433" + "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/77fa7995ac1b21ab60769b7323d600a991a90433", - "reference": "77fa7995ac1b21ab60769b7323d600a991a90433", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", + "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "type": "library", "extra": { @@ -5759,7 +5821,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" }, "funding": [ { @@ -5775,24 +5837,24 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-php81", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", - "reference": "3fb075789fb91f9ad9af537c4012d523085bd5af" + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/3fb075789fb91f9ad9af537c4012d523085bd5af", - "reference": "3fb075789fb91f9ad9af537c4012d523085bd5af", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "type": "library", "extra": { @@ -5835,7 +5897,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.31.0" }, "funding": [ { @@ -5851,20 +5913,20 @@ "type": "tidelift" } ], - "time": "2024-06-19T12:30:46+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/process", - "version": "v6.4.8", + "version": "v6.4.12", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "8d92dd79149f29e89ee0f480254db595f6a6a2c5" + "reference": "3f94e5f13ff58df371a7ead461b6e8068900fbb3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/8d92dd79149f29e89ee0f480254db595f6a6a2c5", - "reference": "8d92dd79149f29e89ee0f480254db595f6a6a2c5", + "url": "https://api.github.com/repos/symfony/process/zipball/3f94e5f13ff58df371a7ead461b6e8068900fbb3", + "reference": "3f94e5f13ff58df371a7ead461b6e8068900fbb3", "shasum": "" }, "require": { @@ -5896,7 +5958,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.4.8" + "source": "https://github.com/symfony/process/tree/v6.4.12" }, "funding": [ { @@ -5912,7 +5974,7 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:49:08+00:00" + "time": "2024-09-17T12:47:12+00:00" }, { "name": "symfony/stopwatch", @@ -5978,16 +6040,16 @@ }, { "name": "vimeo/psalm", - "version": "5.25.0", + "version": "5.26.1", "source": { "type": "git", "url": "https://github.com/vimeo/psalm.git", - "reference": "01a8eb06b9e9cc6cfb6a320bf9fb14331919d505" + "reference": "d747f6500b38ac4f7dfc5edbcae6e4b637d7add0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vimeo/psalm/zipball/01a8eb06b9e9cc6cfb6a320bf9fb14331919d505", - "reference": "01a8eb06b9e9cc6cfb6a320bf9fb14331919d505", + "url": "https://api.github.com/repos/vimeo/psalm/zipball/d747f6500b38ac4f7dfc5edbcae6e4b637d7add0", + "reference": "d747f6500b38ac4f7dfc5edbcae6e4b637d7add0", "shasum": "" }, "require": { @@ -6008,7 +6070,7 @@ "felixfbecker/language-server-protocol": "^1.5.2", "fidry/cpu-core-counter": "^0.4.1 || ^0.5.1 || ^1.0.0", "netresearch/jsonmapper": "^1.0 || ^2.0 || ^3.0 || ^4.0", - "nikic/php-parser": "^4.16", + "nikic/php-parser": "^4.17", "php": "^7.4 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0", "sebastian/diff": "^4.0 || ^5.0 || ^6.0", "spatie/array-to-xml": "^2.17.0 || ^3.0", @@ -6084,7 +6146,7 @@ "issues": "https://github.com/vimeo/psalm/issues", "source": "https://github.com/vimeo/psalm" }, - "time": "2024-06-16T15:08:35+00:00" + "time": "2024-09-08T18:53:08+00:00" }, { "name": "webmozart/assert", diff --git a/phpcs.xml.dist b/phpcs.xml.dist index d8a116c..d7b701b 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -1,11 +1,11 @@ Open Culture Consulting strictly follows PSR standards. + ./bin + ./public ./src - - diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 58e4059..9f5b4f5 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -5,11 +5,14 @@ includes: - vendor/phpstan/phpstan-strict-rules/rules.neon + - vendor/phpstan/phpstan-doctrine/extension.neon + - vendor/phpstan/phpstan-symfony/extension.neon parameters: level: 9 - strictRules: - noVariableVariables: false + treatPhpDocTypesAsCertain: false + ignoreErrors: + - identifier: ternary.shortNotAllowed paths: - bin - public diff --git a/psalm.xml.dist b/psalm.xml.dist index cea884b..241462c 100644 --- a/psalm.xml.dist +++ b/psalm.xml.dist @@ -8,23 +8,40 @@ findUnusedBaselineEntry="true" findUnusedCode="true" findUnusedVariablesAndParams="true" + reportMixedIssues="false" > - + - + - + + + - - - + + + + + + + + diff --git a/src/App.php b/src/App.php index 9b117ea..9d7fcf8 100644 --- a/src/App.php +++ b/src/App.php @@ -31,19 +31,19 @@ use OCC\PSR15\QueueRequestHandler; * @author Sebastian Meyer * @package OAIPMH2 */ -class App +final class App { /** * The PSR-15 Server Request Handler. */ - protected QueueRequestHandler $requestHandler; + private QueueRequestHandler $requestHandler; /** * Instantiate application. */ public function __construct() { - $this->requestHandler = new QueueRequestHandler([new Dispatcher()]); + $this->requestHandler = new QueueRequestHandler(middlewares: [new Dispatcher()]); } /** @@ -54,6 +54,9 @@ class App public function run(): void { $this->requestHandler->handle(); + if ($this->requestHandler->response->hasHeader('Warning')) { + // An exception occured. Maybe we don't want to output the response, but log an error instead? + } $this->requestHandler->respond(); } } diff --git a/src/Configuration.php b/src/Configuration.php index d69d651..5f00e9f 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -23,11 +23,10 @@ declare(strict_types=1); namespace OCC\OaiPmh2; use OCC\Basics\Traits\Singleton; +use OCC\OaiPmh2\Validator\ConfigurationValidator; use Symfony\Component\Filesystem\Exception\FileNotFoundException; use Symfony\Component\Filesystem\Path; -use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Exception\ValidationFailedException; -use Symfony\Component\Validator\Validation; use Symfony\Component\Yaml\Yaml; /** @@ -36,18 +35,18 @@ use Symfony\Component\Yaml\Yaml; * @author Sebastian Meyer * @package OAIPMH2 * - * @property-read string $repositoryName - * @property-read string $adminEmail - * @property-read string $database - * @property-read array $metadataPrefix - * @property-read string $deletedRecords - * @property-read int $maxRecords - * @property-read int $tokenValid + * @property-read string $repositoryName Common name of this repository + * @property-read string $adminEmail Repository contact's e-mail address + * @property-read string $database Database's data source name (DSN) + * @property-read array $metadataPrefix Array of served metadata prefixes + * @property-read string $deletedRecords Repository's deleted records policy + * @property-read int $maxRecords Maximum number of records served per request + * @property-read int $tokenValid Number of seconds resumption tokens are valid * * @template TKey of string * @template TValue of array|int|string */ -class Configuration +final class Configuration { use Singleton; @@ -65,98 +64,6 @@ class Configuration */ protected readonly array $settings; - /** - * Get constraints for configuration array. - * - * @return Assert\Collection The collection of constraints - */ - protected function getValidationConstraints(): Assert\Collection - { - return new Assert\Collection([ - 'repositoryName' => [ - new Assert\Type('string'), - new Assert\NotBlank() - ], - 'adminEmail' => [ - new Assert\Type('string'), - new Assert\Email(['mode' => 'html5']), - new Assert\NotBlank() - ], - 'database' => [ - new Assert\Type('string'), - new Assert\NotBlank() - ], - 'metadataPrefix' => [ - new Assert\Type('array'), - new Assert\All([ - new Assert\Collection([ - 'schema' => [ - new Assert\Type('string'), - new Assert\Url(), - new Assert\NotBlank() - ], - 'namespace' => [ - new Assert\Type('string'), - new Assert\Url(), - new Assert\NotBlank() - ] - ]) - ]) - ], - 'deletedRecords' => [ - new Assert\Type('string'), - new Assert\Choice(['no', 'persistent', 'transient']), - new Assert\NotBlank() - ], - 'maxRecords' => [ - new Assert\Type('int'), - new Assert\Range([ - 'min' => 1, - 'max' => 100 - ]) - ], - 'tokenValid' => [ - new Assert\Type('int'), - new Assert\Range([ - 'min' => 300, - 'max' => 86400 - ]) - ] - ]); - } - - /** - * Read and validate configuration file. - * - * @return array The configuration array - * - * @throws FileNotFoundException if configuration file does not exist - * @throws ValidationFailedException if configuration file is not valid - */ - protected function loadConfigFile(): array - { - $configPath = Path::canonicalize(self::CONFIG_FILE); - if (!is_readable($configPath)) { - throw new FileNotFoundException( - sprintf( - 'Configuration file "%s" not found or not readable.', - $configPath - ), - 500, - null, - $configPath - ); - } - /** @var array */ - $config = Yaml::parseFile($configPath); - $validator = Validation::createValidator(); - $violations = $validator->validate($config, $this->getValidationConstraints()); - if ($violations->count() > 0) { - throw new ValidationFailedException(null, $violations); - } - return $config; - } - /** * Load and validate configuration settings from YAML file. * @@ -165,11 +72,24 @@ class Configuration */ private function __construct() { - try { - $this->settings = $this->loadConfigFile(); - } catch (FileNotFoundException | ValidationFailedException $exception) { - throw $exception; + $configPath = Path::canonicalize(path: self::CONFIG_FILE); + if (!is_readable(filename: $configPath)) { + throw new FileNotFoundException( + message: 'Configuration file not found or not readable.', + code: 500, + path: $configPath + ); } + /** @var array */ + $config = Yaml::parseFile(filename: $configPath); + $violations = ConfigurationValidator::validate(config: $config); + if ($violations->count() > 0) { + throw new ValidationFailedException( + value: null, + violations: $violations + ); + } + $this->settings = $config; } /** @@ -177,7 +97,7 @@ class Configuration * * @param TKey $name The setting to retrieve * - * @return TValue|null The setting or NULL + * @return ?TValue The setting or NULL */ public function __get(string $name): mixed { diff --git a/src/Console.php b/src/Console.php index 7ef0a31..10f0e75 100644 --- a/src/Console.php +++ b/src/Console.php @@ -34,9 +34,53 @@ use Symfony\Component\Console\Output\OutputInterface; * * @author Sebastian Meyer * @package OAIPMH2 + * + * @psalm-type CliArguments = array{ + * identifier: string, + * format: string, + * file: string, + * sets?: list, + * setSpec: string, + * setName: string, + * idColumn: string, + * contentColumn: string, + * dateColumn: string, + * setColumn: string, + * noValidation: bool, + * force: bool + * } */ abstract class Console extends Command { + /** + * This holds the command's arguments and options. + * + * @var CliArguments + */ + protected array $arguments; + + /** + * This holds the entity manager singleton. + */ + protected EntityManager $em; + + /** + * This holds the PHP memory limit in bytes. + */ + protected int $memoryLimit; + + /** + * Flushes changes to the database if memory limit reaches 50%. + * + * @return void + */ + protected function checkMemoryUsage(): void + { + if ((memory_get_usage() / $this->getPhpMemoryLimit()) > 0.5) { + $this->em->flush(); + } + } + /** * Clears the result cache. * @@ -47,11 +91,13 @@ abstract class Console extends Command /** @var Application */ $app = $this->getApplication(); $app->doRun( - new ArrayInput([ - 'command' => 'orm:clear-cache:result', - '--flush' => true - ]), - new NullOutput() + input: new ArrayInput( + parameters: [ + 'command' => 'orm:clear-cache:result', + '--flush' => true + ] + ), + output: new NullOutput() ); } @@ -62,23 +108,26 @@ abstract class Console extends Command */ protected function getPhpMemoryLimit(): int { - $ini = trim(ini_get('memory_limit')); - $limit = (int) $ini; - if ($limit < 0) { - return -1; + if (!isset($this->memoryLimit)) { + $ini = trim(string: ini_get(option: 'memory_limit')); + $limit = (int) $ini; + if ($limit < 0) { + return -1; + } + $unit = strtolower($ini[strlen($ini) - 1]); + switch ($unit) { + case 'g': + $limit *= 1024; + // no break + case 'm': + $limit *= 1024; + // no break + case 'k': + $limit *= 1024; + } + $this->memoryLimit = $limit; } - $unit = strtolower($ini[strlen($ini) - 1]); - switch ($unit) { - case 'g': - $limit *= 1024; - // no break - case 'm': - $limit *= 1024; - // no break - case 'k': - $limit *= 1024; - } - return $limit; + return $this->memoryLimit; } /** @@ -91,32 +140,68 @@ abstract class Console extends Command */ protected function validateInput(InputInterface $input, OutputInterface $output): bool { - /** @var array */ - $arguments = $input->getArguments(); + /** @var CliArguments */ + $mergedArguments = array_merge($input->getArguments(), $input->getOptions()); + $this->arguments = $mergedArguments; - $formats = Database::getInstance()->getMetadataFormats()->getQueryResult(); - if (!array_key_exists($arguments['format'], $formats)) { - $output->writeln([ - '', - sprintf( - ' [ERROR] Metadata format "%s" is not supported. ', - $arguments['format'] - ), - '' - ]); + if (array_key_exists('format', $this->arguments)) { + $formats = $this->em->getMetadataFormats(); + if (!$formats->containsKey(key: $this->arguments['format'])) { + $output->writeln( + messages: [ + '', + sprintf( + format: ' [ERROR] Metadata format "%s" is not supported. ', + values: $this->arguments['format'] + ), + '' + ] + ); + return false; + } + } + if (array_key_exists('file', $this->arguments) && !is_readable(filename: $this->arguments['file'])) { + $output->writeln( + messages: [ + '', + sprintf( + format: ' [ERROR] File "%s" not found or not readable. ', + values: $this->arguments['file'] + ), + '' + ] + ); return false; } - if (!is_readable($arguments['file'])) { - $output->writeln([ - '', - sprintf( - ' [ERROR] File "%s" not found or not readable. ', - $arguments['file'] - ), - '' - ]); - return false; + if (array_key_exists('sets', $this->arguments)) { + $sets = $this->em->getSets(); + $invalidSets = array_diff($this->arguments['sets'], $sets->getKeys()); + if (count($invalidSets) !== 0) { + $output->writeln( + messages: [ + '', + sprintf( + format: ' [ERROR] Sets "%s" are not supported. ', + values: implode('", "', $invalidSets) + ), + '' + ] + ); + return false; + } } return true; } + + /** + * Create new console command instance. + * + * @param ?string $name The name of the command + * passing null means it must be set in configure() + */ + public function __construct(?string $name = null) + { + $this->em = EntityManager::getInstance(); + parent::__construct($name); + } } diff --git a/src/Console/AddRecordCommand.php b/src/Console/AddRecordCommand.php index b949033..28628dd 100644 --- a/src/Console/AddRecordCommand.php +++ b/src/Console/AddRecordCommand.php @@ -23,7 +23,6 @@ declare(strict_types=1); namespace OCC\OaiPmh2\Console; use OCC\OaiPmh2\Console; -use OCC\OaiPmh2\Database; use OCC\OaiPmh2\Entity\Format; use OCC\OaiPmh2\Entity\Record; use OCC\OaiPmh2\Entity\Set; @@ -70,7 +69,7 @@ class AddRecordCommand extends Console $this->addArgument( 'sets', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, - 'The list of sets to associate the record with.' + 'Optional: The list of sets to associate the record with.' ); parent::configure(); } @@ -85,36 +84,31 @@ class AddRecordCommand extends Console */ protected function execute(InputInterface $input, OutputInterface $output): int { - if (!$this->validateInput($input, $output)) { + if (!$this->validateInput(input: $input, output: $output)) { return Command::INVALID; } - /** @var string */ - $identifier = $input->getArgument('identifier'); + /** @var Format */ - $format = Database::getInstance() - ->getEntityManager() - ->getReference(Format::class, $input->getArgument('format')); - /** @var string */ - $file = $input->getArgument('file'); - /** @var string[] */ - $sets = $input->getArgument('sets'); - /** @var string */ - $content = file_get_contents($file); + $format = $this->em->getMetadataFormat(prefix: $this->arguments['format']); + $content = file_get_contents(filename: $this->arguments['file']) ?: ''; - $record = new Record($identifier, $format); + $record = new Record( + identifier: $this->arguments['identifier'], + format: $format + ); if (trim($content) !== '') { - $record->setContent($content); + $record->setContent(data: $content); } - foreach ($sets as $set) { - /** @var Set */ - $setSpec = Database::getInstance() - ->getEntityManager() - ->getReference(Set::class, $set); - $record->addSet($setSpec); + if (array_key_exists('sets', $this->arguments)) { + foreach ($this->arguments['sets'] as $set) { + /** @var Set */ + $setSpec = $this->em->getSet(spec: $set); + $record->addSet(set: $setSpec); + } } - Database::getInstance()->addOrUpdateRecord($record); - Database::getInstance()->pruneOrphanSets(); + $this->em->addOrUpdate(entity: $record); + $this->em->pruneOrphanedSets(); $this->clearResultCache(); @@ -122,7 +116,7 @@ class AddRecordCommand extends Console '', sprintf( ' [OK] Record "%s" with metadata prefix "%s" added or updated successfully! ', - $identifier, + $this->arguments['identifier'], $format->getPrefix() ), '' diff --git a/src/Console/AddSetCommand.php b/src/Console/AddSetCommand.php index 43a171d..c86ee8c 100644 --- a/src/Console/AddSetCommand.php +++ b/src/Console/AddSetCommand.php @@ -23,7 +23,6 @@ declare(strict_types=1); namespace OCC\OaiPmh2\Console; use OCC\OaiPmh2\Console; -use OCC\OaiPmh2\Database; use OCC\OaiPmh2\Entity\Set; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -53,10 +52,10 @@ class AddSetCommand extends Console $this->addArgument( 'setSpec', InputArgument::REQUIRED, - 'The set (spec) to update.', + 'The set (spec) to add or update.', null, function (): array { - return array_keys(Database::getInstance()->getAllSets()->getQueryResult()); + return $this->em->getSets()->getKeys(); } ); $this->addArgument( @@ -67,7 +66,7 @@ class AddSetCommand extends Console $this->addArgument( 'file', InputArgument::OPTIONAL, - 'The optional file containing the set description XML.' + 'Optional: The file containing the set description XML.' ); parent::configure(); } @@ -82,32 +81,20 @@ class AddSetCommand extends Console */ protected function execute(InputInterface $input, OutputInterface $output): int { - /** @var array */ - $arguments = $input->getArguments(); - $description = null; + if (!$this->validateInput(input: $input, output: $output)) { + return Command::INVALID; + } - if (isset($arguments['file'])) { - if (!is_readable($arguments['file'])) { - $output->writeln([ - '', - sprintf( - ' [ERROR] File "%s" not found or not readable. ', - $arguments['file'] - ), - '' - ]); - return Command::INVALID; - } else { - $description = (string) file_get_contents($arguments['file']); - } + if (array_key_exists('file', $this->arguments)) { + $description = file_get_contents(filename: $this->arguments['file']) ?: null; } $set = new Set( - $arguments['setSpec'], - $arguments['setName'], - $description + spec: $this->arguments['setSpec'], + name: $this->arguments['setName'], + description: $description ?? null ); - Database::getInstance()->addOrUpdateSet($set); + $this->em->addOrUpdate(entity: $set); return Command::SUCCESS; } diff --git a/src/Console/CsvImportCommand.php b/src/Console/CsvImportCommand.php index 8919eec..20fd004 100644 --- a/src/Console/CsvImportCommand.php +++ b/src/Console/CsvImportCommand.php @@ -23,9 +23,7 @@ declare(strict_types=1); namespace OCC\OaiPmh2\Console; use DateTime; -use OCC\OaiPmh2\Configuration; use OCC\OaiPmh2\Console; -use OCC\OaiPmh2\Database; use OCC\OaiPmh2\Entity\Format; use OCC\OaiPmh2\Entity\Record; use OCC\OaiPmh2\Entity\Set; @@ -42,6 +40,13 @@ use Symfony\Component\Console\Output\OutputInterface; * * @author Sebastian Meyer * @package OAIPMH2 + * + * @psalm-type ColumnMapping = array{ + * idColumn: int, + * contentColumn: int, + * dateColumn: ?int, + * setColumn: ?int + * } */ #[AsCommand( name: 'oai:records:import:csv', @@ -62,7 +67,7 @@ class CsvImportCommand extends Console 'The format (metadata prefix) of the records.', null, function (): array { - return array_keys(Database::getInstance()->getMetadataFormats()->getQueryResult()); + return $this->em->getMetadataFormats()->getKeys(); } ); $this->addArgument( @@ -88,21 +93,21 @@ class CsvImportCommand extends Console 'dateColumn', 'd', InputOption::VALUE_OPTIONAL, - 'Name of the CSV column which holds the records\' datetime of last change.', + 'Optional: Name of the CSV column which holds the records\' datetime of last change.', 'lastChanged' ); $this->addOption( 'setColumn', 's', InputOption::VALUE_OPTIONAL, - 'Name of the CSV column which holds the comma-separated list of the records\' sets.', + 'Optional: Name of the CSV column which holds the comma-separated list of the records\' sets.', 'sets' ); $this->addOption( 'noValidation', null, InputOption::VALUE_NONE, - 'Skip content validation (improves performance for large record sets).' + 'Optional: Skip content validation (improves ingest performance for large record sets).' ); parent::configure(); } @@ -117,69 +122,60 @@ class CsvImportCommand extends Console */ protected function execute(InputInterface $input, OutputInterface $output): int { - if (!$this->validateInput($input, $output)) { + if (!$this->validateInput(input: $input, output: $output)) { return Command::INVALID; } - $phpMemoryLimit = $this->getPhpMemoryLimit(); - /** @var array */ - $arguments = $input->getArguments(); - /** @var bool */ - $noValidation = $input->getOption('noValidation'); /** @var resource */ - $file = fopen($arguments['file'], 'r'); + $file = fopen(filename: $this->arguments['file'], mode: 'r'); - $columns = $this->getColumnNames($input, $output, $file); - if (count($columns) === 0) { - return Command::INVALID; + $columnMapping = $this->getColumnNames(input: $input, output: $output, file: $file); + + if (!isset($columnMapping)) { + return Command::FAILURE; } $count = 0; $progressIndicator = new ProgressIndicator($output, null, 100, ['⠏', '⠛', '⠹', '⢸', '⣰', '⣤', '⣆', '⡇']); $progressIndicator->start('Importing...'); - while ($row = fgetcsv($file)) { + while ($row = fgetcsv(stream: $file)) { /** @var Format */ - $format = Database::getInstance() - ->getEntityManager() - ->getReference(Format::class, $arguments['format']); - $record = new Record($row[$columns['idColumn']], $format); - if (strlen(trim($row[$columns['contentColumn']])) > 0) { - $record->setContent($row[$columns['contentColumn']], !$noValidation); + $format = $this->em->getMetadataFormat(prefix: $this->arguments['format']); + $record = new Record( + identifier: $row[$columnMapping['idColumn']], + format: $format + ); + if (strlen(trim($row[$columnMapping['contentColumn']])) > 0) { + $record->setContent( + data: $row[$columnMapping['contentColumn']], + validate: !$this->arguments['noValidation'] + ); } - if (isset($columns['dateColumn'])) { - $record->setLastChanged(new DateTime($row[$columns['dateColumn']])); + if (isset($columnMapping['dateColumn'])) { + $record->setLastChanged(dateTime: new DateTime($row[$columnMapping['dateColumn']])); } - if (isset($columns['setColumn'])) { - $sets = $row[$columns['setColumn']]; + if (isset($columnMapping['setColumn'])) { + $sets = $row[$columnMapping['setColumn']]; foreach (explode(',', $sets) as $set) { /** @var Set */ - $setSpec = Database::getInstance() - ->getEntityManager() - ->getReference(Set::class, trim($set)); - $record->addSet($setSpec); + $setSpec = $this->em->getSet(spec: trim($set)); + $record->addSet(set: $setSpec); } } - Database::getInstance()->addOrUpdateRecord($record, true); + $this->em->addOrUpdate(entity: $record, bulkMode: true); ++$count; $progressIndicator->advance(); $progressIndicator->setMessage('Importing... ' . (string) $count . ' records processed.'); - - // Flush to database if memory usage reaches 50% or every 10.000 records. - if ((memory_get_usage() / $phpMemoryLimit) > 0.5 || ($count % 10000) === 0) { - $progressIndicator->setMessage( - 'Importing... ' . (string) $count . ' records processed. Flushing to database...' - ); - Database::getInstance()->flush(true); - } + $this->checkMemoryUsage(); } - Database::getInstance()->flush(true); - Database::getInstance()->pruneOrphanSets(); + $this->em->flush(); + $this->em->pruneOrphanedSets(); $progressIndicator->finish('All done!'); - fclose($file); + fclose(stream: $file); $this->clearResultCache(); @@ -188,7 +184,7 @@ class CsvImportCommand extends Console sprintf( ' [OK] %d records with metadata prefix "%s" were imported successfully! ', $count, - $arguments['format'] + $this->arguments['format'] ), '' ]); @@ -196,49 +192,57 @@ class CsvImportCommand extends Console } /** - * Get the column names of CSV. + * Get the column numbers of CSV. * - * @param InputInterface $input The inputs - * @param OutputInterface $output The output interface + * @param InputInterface $input The input + * @param OutputInterface $output The output * @param resource $file The handle for the CSV file * - * @return array The mapped column names + * @return ?ColumnMapping The mapped columns or NULL in case of an error */ - protected function getColumnNames(InputInterface $input, OutputInterface $output, $file): array + protected function getColumnNames(InputInterface $input, OutputInterface $output, $file): ?array { - /** @var array */ - $options = $input->getOptions(); - - $columns = []; + /** @var array{idColumn: string, contentColumn: string, dateColumn: string, setColumn: string} */ + $columns = [ + 'idColumn' => $input->getOption('idColumn'), + 'contentColumn' => $input->getOption('contentColumn'), + 'dateColumn' => $input->getOption('dateColumn'), + 'setColumn' => $input->getOption('setColumn') + ]; $headers = fgetcsv($file); - if (!is_array($headers)) { + if (!is_array($headers) || !isset($headers[0])) { $output->writeln([ '', sprintf( - ' [ERROR] File "%s" does not contain valid CSV. ', - stream_get_meta_data($file)['uri'] ?? 'unknown' + format: ' [ERROR] File "%s" does not contain valid CSV. ', + /** @phpstan-ignore-next-line - URI is always set for fopen() resources. */ + values: stream_get_meta_data(stream: $file)['uri'] ?: 'unknown' ), '' ]); - return []; - } else { - $headers = array_flip($headers); - } - foreach ($options as $option => $value) { - $columns[$option] = $headers[$value] ?? null; + return null; } + /** @var array */ + $headers = array_flip($headers); + + $callback = function (string $column) use ($headers): ?int { + return array_key_exists($column, $headers) ? $headers[$column] : null; + }; + + $columns = array_map($callback, $columns); if (!isset($columns['idColumn']) || !isset($columns['contentColumn'])) { $output->writeln([ '', sprintf( - ' [ERROR] File "%s" does not contain valid CSV. ', - stream_get_meta_data($file)['uri'] ?? 'unknown' + format: ' [ERROR] File "%s" does not contain mandatory columns. ', + /** @phpstan-ignore-next-line - URI is always set for fopen() resources. */ + values: stream_get_meta_data($file)['uri'] ?: 'unknown' ), '' ]); - return []; + return null; } return $columns; } diff --git a/src/Console/DeleteRecordCommand.php b/src/Console/DeleteRecordCommand.php index 9e64661..240841a 100644 --- a/src/Console/DeleteRecordCommand.php +++ b/src/Console/DeleteRecordCommand.php @@ -23,9 +23,6 @@ declare(strict_types=1); namespace OCC\OaiPmh2\Console; use OCC\OaiPmh2\Console; -use OCC\OaiPmh2\Database; -use OCC\OaiPmh2\Entity\Format; -use OCC\OaiPmh2\Entity\Record; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -40,7 +37,7 @@ use Symfony\Component\Console\Output\OutputInterface; */ #[AsCommand( name: 'oai:records:delete', - description: 'Delete a record from database' + description: 'Delete a record while obeying deleted record policy' )] class DeleteRecordCommand extends Console { @@ -74,28 +71,24 @@ class DeleteRecordCommand extends Console */ protected function execute(InputInterface $input, OutputInterface $output): int { - /** @var array */ - $arguments = $input->getArguments(); - $entityManager = Database::getInstance()->getEntityManager(); + if (!$this->validateInput(input: $input, output: $output)) { + return Command::INVALID; + } - $format = $entityManager->getReference(Format::class, $arguments['format']); - $record = $entityManager->find( - Record::class, - [ - 'identifier' => $arguments['identifier'], - 'format' => $format - ] + $record = $this->em->getRecord( + identifier: $this->arguments['identifier'], + format: $this->arguments['format'] ); if (isset($record)) { - Database::getInstance()->deleteRecord($record); + $this->em->delete(entity: $record); $this->clearResultCache(); $output->writeln([ '', sprintf( - ' [OK] Record "%s" with metadata prefix "%s" successfully deleted. ', - $arguments['identifier'], - $arguments['format'] + ' [OK] Record "%s" with metadata prefix "%s" successfully (marked as) deleted. ', + $this->arguments['identifier'], + $this->arguments['format'] ), '' ]); @@ -105,8 +98,8 @@ class DeleteRecordCommand extends Console '', sprintf( ' [ERROR] Record "%s" with metadata prefix "%s" not found. ', - $arguments['identifier'], - $arguments['format'] + $this->arguments['identifier'], + $this->arguments['format'] ), '' ]); diff --git a/src/Console/PruneDeletedRecordsCommand.php b/src/Console/PruneDeletedRecordsCommand.php index 2ae72cd..4e22a43 100644 --- a/src/Console/PruneDeletedRecordsCommand.php +++ b/src/Console/PruneDeletedRecordsCommand.php @@ -24,7 +24,6 @@ namespace OCC\OaiPmh2\Console; use OCC\OaiPmh2\Configuration; use OCC\OaiPmh2\Console; -use OCC\OaiPmh2\Database; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -54,7 +53,7 @@ class PruneDeletedRecordsCommand extends Console 'force', 'f', InputOption::VALUE_NONE, - 'Deletes records even under "transient" policy.' + 'Optional: Deletes records even under "transient" policy.' ); parent::configure(); } @@ -75,13 +74,13 @@ class PruneDeletedRecordsCommand extends Console $policy === 'no' or ($policy === 'transient' && $forced) ) { - $deleted = Database::getInstance()->pruneDeletedRecords(); + $deleted = $this->em->pruneDeletedRecords(); $this->clearResultCache(); $output->writeln([ '', sprintf( - ' [OK] %d records are deleted and were successfully removed! ', - $deleted + format: ' [OK] %d deleted records were successfully removed! ', + values: $deleted ), '' ]); diff --git a/src/Console/PruneResumptionTokensCommand.php b/src/Console/PruneResumptionTokensCommand.php index 267c3b8..e76b7b0 100644 --- a/src/Console/PruneResumptionTokensCommand.php +++ b/src/Console/PruneResumptionTokensCommand.php @@ -23,7 +23,6 @@ declare(strict_types=1); namespace OCC\OaiPmh2\Console; use OCC\OaiPmh2\Console; -use OCC\OaiPmh2\Database; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -51,12 +50,12 @@ class PruneResumptionTokensCommand extends Console */ protected function execute(InputInterface $input, OutputInterface $output): int { - $expired = Database::getInstance()->pruneResumptionTokens(); + $expired = $this->em->pruneExpiredTokens(); $output->writeln([ '', sprintf( - ' [OK] %d resumption tokens are expired and were successfully deleted! ', - $expired + format: ' [OK] %d expired resumption tokens were successfully deleted! ', + values: $expired ), '' ]); diff --git a/src/Console/UpdateFormatsCommand.php b/src/Console/UpdateFormatsCommand.php index 3d16243..d56982f 100644 --- a/src/Console/UpdateFormatsCommand.php +++ b/src/Console/UpdateFormatsCommand.php @@ -24,7 +24,6 @@ namespace OCC\OaiPmh2\Console; use OCC\OaiPmh2\Configuration; use OCC\OaiPmh2\Console; -use OCC\OaiPmh2\Database; use OCC\OaiPmh2\Entity\Format; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -57,56 +56,55 @@ class UpdateFormatsCommand extends Console /** @var array> */ $formats = Configuration::getInstance()->metadataPrefix; $this->clearResultCache(); - $inDatabase = Database::getInstance() - ->getMetadataFormats() - ->getQueryResult(); - $added = 0; - $deleted = 0; + $inDatabase = $this->em->getMetadataFormats(); $failure = false; foreach ($formats as $prefix => $format) { - if (array_key_exists($prefix, $inDatabase)) { - if ( - $format['namespace'] === $inDatabase[$prefix]->getNamespace() - and $format['schema'] === $inDatabase[$prefix]->getSchema() - ) { - continue; - } + if ( + $inDatabase->containsKey(key: $prefix) + /** @phpstan-ignore-next-line - $inDatabase[$prefix] is always of type Format. */ + and $format['namespace'] === $inDatabase[$prefix]->getNamespace() + /** @phpstan-ignore-next-line - $inDatabase[$prefix] is always of type Format. */ + and $format['schema'] === $inDatabase[$prefix]->getSchema() + ) { + continue; } try { - $format = new Format($prefix, $format['namespace'], $format['schema']); - Database::getInstance()->addOrUpdateMetadataFormat($format); - ++$added; + $format = new Format( + prefix: $prefix, + namespace: $format['namespace'], + schema: $format['schema'] + ); + $this->em->addOrUpdate(entity: $format); $output->writeln([ sprintf( - ' [OK] Metadata format "%s" added or updated successfully! ', - $prefix + format: ' [OK] Metadata format "%s" added or updated successfully! ', + values: $prefix ) ]); } catch (ValidationFailedException $exception) { $failure = true; $output->writeln([ sprintf( - ' [ERROR] Could not add or update metadata format "%s". ', - $prefix + format: ' [ERROR] Could not add or update metadata format "%s". ', + values: $prefix ), $exception->getMessage() ]); } } - foreach (array_keys($inDatabase) as $prefix) { - if (!array_key_exists($prefix, $formats)) { - Database::getInstance()->deleteMetadataFormat($inDatabase[$prefix]); - ++$deleted; - $output->writeln([ - sprintf( - ' [OK] Metadata format "%s" and all associated records deleted successfully! ', - $prefix - ) - ]); - } + foreach (array_diff($inDatabase->getKeys(), array_keys($formats)) as $prefix) { + /** @var Format */ + $format = $inDatabase[$prefix]; + $this->em->delete(entity: $format); + $output->writeln([ + sprintf( + format: ' [OK] Metadata format "%s" and all associated records deleted successfully! ', + values: $prefix + ) + ]); } $this->clearResultCache(); - $currentFormats = array_keys(Database::getInstance()->getMetadataFormats()->getQueryResult()); + $currentFormats = $this->em->getMetadataFormats()->getKeys(); if (count($currentFormats) > 0) { $output->writeln( [ @@ -118,7 +116,7 @@ class UpdateFormatsCommand extends Console ' command "php bin/cli oai:formats:update" again! ', '' ], - 1 | 16 + OutputInterface::OUTPUT_NORMAL | OutputInterface::VERBOSITY_QUIET ); } else { $output->writeln( @@ -129,13 +127,9 @@ class UpdateFormatsCommand extends Console ' command "php bin/cli oai:formats:update" again! ', '' ], - 1 | 16 + OutputInterface::OUTPUT_NORMAL | OutputInterface::VERBOSITY_QUIET ); } - if (!$failure) { - return Command::SUCCESS; - } else { - return Command::FAILURE; - } + return $failure ? Command::FAILURE : Command::SUCCESS; } } diff --git a/src/Database.php b/src/Database.php deleted file mode 100644 index c34a3a2..0000000 --- a/src/Database.php +++ /dev/null @@ -1,573 +0,0 @@ - - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -declare(strict_types=1); - -namespace OCC\OaiPmh2; - -use DateTime; -use Doctrine\Common\Collections\Criteria; -use Doctrine\DBAL\DriverManager; -use Doctrine\DBAL\Schema\AbstractAsset; -use Doctrine\DBAL\Tools\DsnParser; -use Doctrine\ORM\AbstractQuery; -use Doctrine\ORM\Configuration as DoctrineConfiguration; -use Doctrine\ORM\EntityManager; -use Doctrine\ORM\Mapping\Driver\AttributeDriver; -use Doctrine\ORM\Proxy\ProxyFactory; -use Doctrine\ORM\Query\Expr\Join; -use Doctrine\ORM\Tools\Pagination\Paginator; -use OCC\Basics\Traits\Singleton; -use OCC\OaiPmh2\Entity\Format; -use OCC\OaiPmh2\Entity\Record; -use OCC\OaiPmh2\Entity\Set; -use OCC\OaiPmh2\Entity\Token; -use OCC\OaiPmh2\Result; -use Symfony\Component\Cache\Adapter\PhpFilesAdapter; -use Symfony\Component\Filesystem\Path; - -/** - * Handles all database shenanigans. - * - * @author Sebastian Meyer - * @package OAIPMH2 - * - * @template Formats of array - * @template Records of array - * @template Sets of array - */ -class Database -{ - use Singleton; - - protected const DB_TABLES = [ - 'formats', - 'records', - 'records_sets', - 'sets', - 'tokens' - ]; - - /** - * This holds the Doctrine entity manager. - */ - protected EntityManager $entityManager; - - /** - * Add or update metadata format. - * - * @param Format $newFormat The metadata format - * - * @return void - */ - public function addOrUpdateMetadataFormat(Format $newFormat): void - { - $oldFormat = $this->entityManager->find(Format::class, $newFormat->getPrefix()); - if (isset($oldFormat)) { - $oldFormat->setNamespace($newFormat->getNamespace()); - $oldFormat->setSchema($newFormat->getSchema()); - } else { - $this->entityManager->persist($newFormat); - } - $this->entityManager->flush(); - } - - /** - * Add or update record. - * - * @param Record $newRecord The record - * @param bool $bulkMode Should we operate in bulk mode (no flush)? - * - * @return void - */ - public function addOrUpdateRecord(Record $newRecord, bool $bulkMode = false): void - { - $oldRecord = $this->entityManager->find( - Record::class, - [ - 'identifier' => $newRecord->getIdentifier(), - 'format' => $newRecord->getFormat() - ] - ); - if (isset($oldRecord)) { - if ($newRecord->hasContent() || Configuration::getInstance()->deletedRecords !== 'no') { - $oldRecord->setContent($newRecord->getContent(), false); - $oldRecord->setLastChanged($newRecord->getLastChanged()); - // Add new sets. - foreach (array_diff($newRecord->getSets(), $oldRecord->getSets()) as $newSet) { - $oldRecord->addSet($newSet); - } - // Remove old sets. - foreach (array_diff($oldRecord->getSets(), $newRecord->getSets()) as $oldSet) { - $oldRecord->removeSet($oldSet); - } - } else { - $this->entityManager->remove($oldRecord); - } - } else { - if ($newRecord->hasContent() || Configuration::getInstance()->deletedRecords !== 'no') { - $this->entityManager->persist($newRecord); - } - } - if (!$bulkMode) { - $this->entityManager->flush(); - } - } - - /** - * Add or update set. - * - * @param Set $newSet The set - * - * @return void - */ - public function addOrUpdateSet(Set $newSet): void - { - $oldSet = $this->entityManager->find(Set::class, $newSet->getSpec()); - if (isset($oldSet)) { - $oldSet->setName($newSet->getName()); - $oldSet->setDescription($newSet->getDescription()); - } else { - $this->entityManager->persist($newSet); - } - $this->entityManager->flush(); - } - - /** - * Delete metadata format and all associated records. - * - * @param Format $format The metadata format - * - * @return void - */ - public function deleteMetadataFormat(Format $format): void - { - $dql = $this->entityManager->createQueryBuilder(); - $dql->delete(Record::class, 'record') - ->where($dql->expr()->eq('record.format', ':format')) - ->setParameter('format', $format->getPrefix()); - $query = $dql->getQuery(); - $query->execute(); - - // Explicitly remove associations with sets for deleted records. - $sql = $this->entityManager->getConnection(); - $sql->executeStatement("DELETE FROM records_sets WHERE record_format='{$format->getPrefix()}'"); - - $this->entityManager->remove($format); - $this->entityManager->flush(); - - $this->pruneOrphanSets(); - } - - /** - * Delete a record. - * - * @param Record $record The record - * - * @return void - */ - public function deleteRecord(Record $record): void - { - if (Configuration::getInstance()->deletedRecords === 'no') { - $this->entityManager->remove($record); - } else { - $record->setContent(null); - $record->setLastChanged(new DateTime()); - } - $this->entityManager->flush(); - $this->pruneOrphanSets(); - } - - /** - * Flush all changes to the database. - * - * @param bool $clear Should the entity manager get cleared as well? - * @return void - */ - public function flush(bool $clear = false): void - { - $this->entityManager->flush(); - if ($clear) { - $this->entityManager->clear(); - } - } - - /** - * Get all sets without pagination. - * - * @return Result The sets - */ - public function getAllSets(): Result - { - $dql = $this->entityManager->createQueryBuilder(); - $dql->select('sets') - ->from(Set::class, 'sets', 'sets.spec'); - $query = $dql->getQuery(); - $query->enableResultCache(); - /** @var Sets $resultQuery */ - $resultQuery = $query->getResult(); - return new Result($resultQuery); - } - - /** - * Get the earliest datestamp of any record. - * - * @return string The earliest datestamp - */ - public function getEarliestDatestamp(): string - { - $timestamp = '0000-00-00T00:00:00Z'; - $dql = $this->entityManager->createQueryBuilder(); - $dql->select($dql->expr()->min('record.lastChanged')) - ->from(Record::class, 'record'); - $query = $dql->getQuery(); - $query->enableResultCache(); - /** @var ?string $result */ - $result = $query->getOneOrNullResult(AbstractQuery::HYDRATE_SCALAR_COLUMN); - return $result ?? $timestamp; - } - - /** - * Get the Doctrine entity manager. - * - * @return EntityManager The entity manager instance - */ - public function getEntityManager(): EntityManager - { - return $this->entityManager; - } - - /** - * Get all metadata prefixes. - * - * @param ?string $identifier Optional record identifier - * - * @return Result The metadata prefixes - */ - public function getMetadataFormats(?string $identifier = null): Result - { - $dql = $this->entityManager->createQueryBuilder(); - $dql->select('format') - ->from(Format::class, 'format', 'format.prefix'); - if (isset($identifier)) { - $dql->innerJoin(Record::class, 'record') - ->where( - $dql->expr()->andX( - $dql->expr()->eq('record.identifier', ':identifier'), - $dql->expr()->isNotNull('record.content') - ) - ) - ->setParameter('identifier', $identifier); - } - $query = $dql->getQuery(); - $query->enableResultCache(); - /** @var Formats $queryResult */ - $queryResult = $query->getResult(); - return new Result($queryResult); - } - - /** - * Get a single record. - * - * @param string $identifier The record identifier - * @param Format $format The metadata format - * - * @return ?Record The record or NULL on failure - */ - public function getRecord(string $identifier, Format $format): ?Record - { - return $this->entityManager->find( - Record::class, - [ - 'identifier' => $identifier, - 'format' => $format - ] - ); - } - - /** - * Get list of records. - * - * @param string $verb The currently requested verb ('ListIdentifiers' or 'ListRecords') - * @param Format $metadataPrefix The metadata format - * @param int $counter Counter for split result sets - * @param ?DateTime $from The "from" datestamp - * @param ?DateTime $until The "until" datestamp - * @param ?Set $set The set spec - * - * @return Result The records and possibly a resumtion token - */ - public function getRecords( - string $verb, - Format $metadataPrefix, - int $counter = 0, - ?DateTime $from = null, - ?DateTime $until = null, - ?Set $set = null - ): Result { - $maxRecords = Configuration::getInstance()->maxRecords; - $cursor = $counter * $maxRecords; - - $dql = $this->entityManager->createQueryBuilder(); - $dql->select('record') - ->from(Record::class, 'record', 'record.identifier') - ->where($dql->expr()->eq('record.format', ':metadataPrefix')) - ->setParameter('metadataPrefix', $metadataPrefix) - ->setFirstResult($cursor) - ->setMaxResults($maxRecords); - if (isset($from)) { - $dql->andWhere($dql->expr()->gte('record.lastChanged', ':from')); - $dql->setParameter('from', $from); - $from = $from->format('Y-m-d\TH:i:s\Z'); - } - if (isset($until)) { - $dql->andWhere($dql->expr()->lte('record.lastChanged', ':until')); - $dql->setParameter('until', $until); - $until = $until->format('Y-m-d\TH:i:s\Z'); - } - if (isset($set)) { - $dql->innerJoin( - Set::class, - 'sets', - Join::WITH, - $dql->expr()->orX( - $dql->expr()->eq('sets.spec', ':setSpec'), - $dql->expr()->like('sets.spec', ':setLike') - ) - ); - $dql->setParameter('setSpec', $set->getSpec()); - $dql->setParameter('setLike', $set->getSpec() . ':%'); - $set = $set->getSpec(); - } - $query = $dql->getQuery(); - /** @var Records $queryResult */ - $queryResult = $query->getResult(); - $result = new Result($queryResult); - $paginator = new Paginator($query, true); - if (count($paginator) > ($cursor + count($result))) { - $token = new Token($verb, [ - 'counter' => $counter + 1, - 'completeListSize' => count($paginator), - 'metadataPrefix' => $metadataPrefix->getPrefix(), - 'from' => $from, - 'until' => $until, - 'set' => $set - ]); - $this->entityManager->persist($token); - $this->entityManager->flush(); - $result->setResumptionToken($token); - } - return $result; - } - - /** - * Get resumption token. - * - * @param string $token The token - * @param string $verb The current verb to validate token - * - * @return ?Token The resumption token or NULL if invalid - */ - public function getResumptionToken(string $token, string $verb): ?Token - { - $dql = $this->entityManager->createQueryBuilder(); - $dql->select('token') - ->from(Token::class, 'token') - ->where($dql->expr()->gte('token.validUntil', ':now')) - ->andWhere($dql->expr()->eq('token.token', ':token')) - ->andWhere($dql->expr()->eq('token.verb', ':verb')) - ->setParameter('now', new DateTime()) - ->setParameter('token', $token) - ->setParameter('verb', $verb) - ->setMaxResults(1); - $query = $dql->getQuery(); - /** @var ?Token */ - return $query->getOneOrNullResult(); - } - - /** - * Get all sets. - * - * @param int $counter Counter for split result sets - * - * @return Result The sets and possibly a resumption token - */ - public function getSets(int $counter = 0): Result - { - $maxRecords = Configuration::getInstance()->maxRecords; - $cursor = $counter * $maxRecords; - - $dql = $this->entityManager->createQueryBuilder(); - $dql->select('sets') - ->from(Set::class, 'sets', 'sets.spec') - ->setFirstResult($cursor) - ->setMaxResults($maxRecords); - $query = $dql->getQuery(); - $query->enableResultCache(); - /** @var Sets $queryResult */ - $queryResult = $query->getResult(); - $result = new Result($queryResult); - $paginator = new Paginator($query, false); - if (count($paginator) > ($cursor + count($result))) { - $token = new Token('ListSets', [ - 'counter' => $counter + 1, - 'completeListSize' => count($paginator) - ]); - $this->entityManager->persist($token); - $this->entityManager->flush(); - $result->setResumptionToken($token); - } - return $result; - } - - /** - * Check if a record identifier exists. - * - * @param string $identifier The record identifier - * - * @return bool Whether the identifier exists - */ - public function idDoesExist(string $identifier): bool - { - $dql = $this->entityManager->createQueryBuilder(); - $dql->select($dql->expr()->count('record.identifier')) - ->from(Record::class, 'record') - ->where($dql->expr()->eq('record.identifier', ':identifier')) - ->setParameter('identifier', $identifier); - $query = $dql->getQuery(); - return (bool) $query->getOneOrNullResult(AbstractQuery::HYDRATE_SCALAR_COLUMN); - } - - /** - * Prune deleted records. - * - * @return int The number of removed records - */ - public function pruneDeletedRecords(): int - { - $repository = $this->entityManager->getRepository(Record::class); - $criteria = Criteria::create()->where(Criteria::expr()->isNull('content')); - $records = $repository->matching($criteria); - foreach ($records as $record) { - $this->entityManager->remove($record); - } - $this->entityManager->flush(); - $this->pruneOrphanSets(); - return count($records); - } - - /** - * Prune orphan sets. - * - * @return int The number of removed sets - */ - public function pruneOrphanSets(): int - { - $repository = $this->entityManager->getRepository(Set::class); - $sets = $repository->findAll(); - $count = 0; - foreach ($sets as $set) { - if ($set->isEmpty()) { - $this->entityManager->remove($set); - ++$count; - } - } - $this->entityManager->flush(); - return $count; - } - - /** - * Prune expired resumption tokens. - * - * @return int The number of deleted tokens - */ - public function pruneResumptionTokens(): int - { - $repository = $this->entityManager->getRepository(Token::class); - $criteria = Criteria::create()->where(Criteria::expr()->lt('validUntil', new DateTime())); - $tokens = $repository->matching($criteria); - foreach ($tokens as $token) { - $this->entityManager->remove($token); - } - $this->entityManager->flush(); - return count($tokens); - } - - /** - * This is a singleton class, thus the constructor is private. - * - * Usage: Get an instance of this class by calling Database::getInstance() - */ - private function __construct() - { - $configuration = new DoctrineConfiguration(); - $configuration->setAutoGenerateProxyClasses( - ProxyFactory::AUTOGENERATE_NEVER - ); - $configuration->setMetadataCache( - new PhpFilesAdapter( - 'Metadata', - 0, - __DIR__ . '/../var/cache' - ) - ); - $configuration->setMetadataDriverImpl( - new AttributeDriver([__DIR__ . '/Entity']) - ); - $configuration->setProxyDir(__DIR__ . '/../var/generated'); - $configuration->setProxyNamespace('OCC\OaiPmh2\Entity\Proxy'); - $configuration->setQueryCache( - new PhpFilesAdapter( - 'Query', - 0, - __DIR__ . '/../var/cache' - ) - ); - $configuration->setResultCache( - new PhpFilesAdapter( - 'Result', - 0, - __DIR__ . '/../var/cache' - ) - ); - $configuration->setSchemaAssetsFilter( - static function (string|AbstractAsset $assetName): bool { - if ($assetName instanceof AbstractAsset) { - $assetName = $assetName->getName(); - } - return in_array($assetName, self::DB_TABLES, true); - } - ); - - $baseDir = Path::canonicalize(__DIR__ . '/../'); - $dsn = str_replace('%BASEDIR%', $baseDir, Configuration::getInstance()->database); - $parser = new DsnParser([ - 'mariadb' => 'pdo_mysql', - 'mssql' => 'pdo_sqlsrv', - 'mysql' => 'pdo_mysql', - 'oracle' => 'pdo_oci', - 'postgresql' => 'pdo_pgsql', - 'sqlite' => 'pdo_sqlite' - ]); - $connection = DriverManager::getConnection($parser->parse($dsn), $configuration); - - $this->entityManager = new EntityManager($connection, $configuration); - } -} diff --git a/src/Entity.php b/src/Entity.php index 4e666ea..0f46dcf 100644 --- a/src/Entity.php +++ b/src/Entity.php @@ -22,9 +22,10 @@ declare(strict_types=1); namespace OCC\OaiPmh2; -use Symfony\Component\Validator\Constraints as Assert; +use OCC\OaiPmh2\Validator\RegExValidator; +use OCC\OaiPmh2\Validator\UrlValidator; +use OCC\OaiPmh2\Validator\XmlValidator; use Symfony\Component\Validator\Exception\ValidationFailedException; -use Symfony\Component\Validator\Validation; /** * Base class for all Doctrine/ORM entities. @@ -45,17 +46,13 @@ abstract class Entity */ protected function validateUrl(string $url): string { - $url = trim($url); - $validator = Validation::createValidator(); - $violations = $validator->validate( - $url, - [ - new Assert\Url(), - new Assert\NotBlank() - ] - ); + $url = trim(string: $url); + $violations = UrlValidator::validate(url: $url); if ($violations->count() > 0) { - throw new ValidationFailedException(null, $violations); + throw new ValidationFailedException( + value: null, + violations: $violations + ); } return $url; } @@ -72,18 +69,12 @@ abstract class Entity */ protected function validateRegEx(string $string, string $regEx): string { - $validator = Validation::createValidator(); - $violations = $validator->validate( - $string, - [ - new Assert\Regex([ - 'pattern' => $regEx, - 'message' => 'This value does not match the regular expression "{{ pattern }}".' - ]) - ] - ); + $violations = RegExValidator::validate(string: $string, regEx: $regEx); if ($violations->count() > 0) { - throw new ValidationFailedException(null, $violations); + throw new ValidationFailedException( + value: null, + violations: $violations + ); } return $string; } @@ -99,19 +90,12 @@ abstract class Entity */ protected function validateXml(string $xml): string { - $validator = Validation::createValidator(); - $violations = $validator->validate( - $xml, - [ - new Assert\Type('string'), - new Assert\NotBlank() - ] - ); - if ( - $violations->count() > 0 - or simplexml_load_string($xml) === false - ) { - throw new ValidationFailedException(null, $violations); + $violations = XmlValidator::validate(xml: $xml); + if ($violations->count() > 0) { + throw new ValidationFailedException( + value: null, + violations: $violations + ); } return $xml; } diff --git a/src/Entity/Format.php b/src/Entity/Format.php index b7ad9b0..cf7ad73 100644 --- a/src/Entity/Format.php +++ b/src/Entity/Format.php @@ -22,8 +22,11 @@ declare(strict_types=1); namespace OCC\OaiPmh2\Entity; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use OCC\OaiPmh2\Entity; +use OCC\OaiPmh2\Repository\FormatRepository; use Symfony\Component\Validator\Exception\ValidationFailedException; /** @@ -32,9 +35,9 @@ use Symfony\Component\Validator\Exception\ValidationFailedException; * @author Sebastian Meyer * @package OAIPMH2 */ -#[ORM\Entity] +#[ORM\Entity(repositoryClass: FormatRepository::class)] #[ORM\Table(name: 'formats')] -class Format extends Entity +final class Format extends Entity { /** * The unique metadata prefix. @@ -56,7 +59,21 @@ class Format extends Entity private string $xmlSchema; /** - * Get the format's namespace URI. + * The format's associated records. + * + * @var Collection + */ + #[ORM\OneToMany( + targetEntity: Record::class, + mappedBy: 'format', + fetch: 'EXTRA_LAZY', + orphanRemoval: true, + indexBy: 'identifier' + )] + private Collection $records; + + /** + * Get the namespace URI for this format. * * @return string The namespace URI */ @@ -76,7 +93,17 @@ class Format extends Entity } /** - * Get the format's schema URL. + * Get the associated records for this format. + * + * @return Collection The collection of records + */ + public function getRecords(): Collection + { + return $this->records; + } + + /** + * Get the schema URL for this format. * * @return string The schema URL */ @@ -86,7 +113,7 @@ class Format extends Entity } /** - * Set the format's namespace URI. + * Set the namespace URI for this format. * * @param string $namespace The namespace URI * @@ -97,14 +124,14 @@ class Format extends Entity public function setNamespace(string $namespace): void { try { - $this->namespace = $this->validateUrl($namespace); + $this->namespace = $this->validateUrl(url: $namespace); } catch (ValidationFailedException $exception) { throw $exception; } } /** - * Set the format's schema URL. + * Set the schema URL for this format. * * @param string $schema The schema URL * @@ -115,7 +142,7 @@ class Format extends Entity public function setSchema(string $schema): void { try { - $this->xmlSchema = $this->validateUrl($schema); + $this->xmlSchema = $this->validateUrl(url: $schema); } catch (ValidationFailedException $exception) { throw $exception; } @@ -133,9 +160,13 @@ class Format extends Entity public function __construct(string $prefix, string $namespace, string $schema) { try { - $this->prefix = $this->validateRegEx($prefix, '/^[A-Za-z0-9\-_\.!~\*\'\(\)]+$/'); - $this->setNamespace($namespace); - $this->setSchema($schema); + $this->prefix = $this->validateRegEx( + string: $prefix, + regEx: '/^[A-Za-z0-9\-_\.!~\*\'\(\)]+$/' + ); + $this->setNamespace(namespace: $namespace); + $this->setSchema(schema: $schema); + $this->records = new ArrayCollection(); } catch (ValidationFailedException $exception) { throw $exception; } diff --git a/src/Entity/Record.php b/src/Entity/Record.php index 1804917..42dd4c9 100644 --- a/src/Entity/Record.php +++ b/src/Entity/Record.php @@ -27,6 +27,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use OCC\OaiPmh2\Entity; +use OCC\OaiPmh2\Repository\RecordRepository; use Symfony\Component\Validator\Exception\ValidationFailedException; /** @@ -35,13 +36,13 @@ use Symfony\Component\Validator\Exception\ValidationFailedException; * @author Sebastian Meyer * @package OAIPMH2 */ -#[ORM\Entity] +#[ORM\Entity(repositoryClass: RecordRepository::class)] #[ORM\Table(name: 'records')] #[ORM\Index(name: 'identifier_idx', columns: ['identifier'])] #[ORM\Index(name: 'format_idx', columns: ['format'])] #[ORM\Index(name: 'last_changed_idx', columns: ['last_changed'])] #[ORM\Index(name: 'format_last_changed_idx', columns: ['format', 'last_changed'])] -class Record extends Entity +final class Record extends Entity { /** * The record identifier. @@ -54,8 +55,16 @@ class Record extends Entity * The associated format. */ #[ORM\Id] - #[ORM\ManyToOne(targetEntity: Format::class, fetch: 'EXTRA_LAZY')] - #[ORM\JoinColumn(name: 'format', referencedColumnName: 'prefix')] + #[ORM\ManyToOne( + targetEntity: Format::class, + fetch: 'EXTRA_LAZY', + inversedBy: 'records' + )] + #[ORM\JoinColumn( + name: 'format', + referencedColumnName: 'prefix', + onDelete: 'CASCADE' + )] private Format $format; /** @@ -75,7 +84,13 @@ class Record extends Entity * * @var Collection */ - #[ORM\ManyToMany(targetEntity: Set::class, inversedBy: 'records', indexBy: 'spec', fetch: 'EXTRA_LAZY', cascade: ['persist'])] + #[ORM\ManyToMany( + targetEntity: Set::class, + inversedBy: 'records', + cascade: ['persist'], + fetch: 'EXTRA_LAZY', + indexBy: 'spec' + )] #[ORM\JoinTable(name: 'records_sets')] #[ORM\JoinColumn(name: 'record_identifier', referencedColumnName: 'identifier')] #[ORM\JoinColumn(name: 'record_format', referencedColumnName: 'format')] @@ -91,9 +106,9 @@ class Record extends Entity */ public function addSet(Set $set): void { - if (!$this->sets->contains($set)) { - $this->sets->add($set); - $set->addRecord($this); + if (!$this->sets->contains(element: $set)) { + $this->sets->add(element: $set); + $set->addRecord(record: $this); } } @@ -146,23 +161,26 @@ class Record extends Entity */ public function getSet(string $setSpec): ?Set { - return $this->sets->get($setSpec); + return $this->sets->get(key: $setSpec); } /** * Get a collection of associated sets. * - * @return array The associated sets + * @return Collection The associated sets */ - public function getSets(): array + public function getSets(): Collection { - return $this->sets->toArray(); + return $this->sets; } /** * Whether this record has any content. * * @return bool TRUE if content exists, FALSE otherwise + * + * @psalm-assert-if-true string $this->content + * @psalm-assert-if-true string $this->getContent() */ public function hasContent(): bool { @@ -178,9 +196,9 @@ class Record extends Entity */ public function removeSet(Set $set): void { - if ($this->sets->contains($set)) { - $this->sets->removeElement($set); - $set->removeRecord($this); + if ($this->sets->contains(element: $set)) { + $this->sets->removeElement(element: $set); + $set->removeRecord(record: $this); } } @@ -200,7 +218,7 @@ class Record extends Entity $data = trim($data); if ($validate) { try { - $data = $this->validateXml($data); + $data = $this->validateXml(xml: $data); } catch (ValidationFailedException $exception) { throw $exception; } @@ -250,13 +268,13 @@ class Record extends Entity { try { $this->identifier = $this->validateRegEx( - $identifier, + string: $identifier, // xs:anyURI - '/^(([a-zA-Z][0-9a-zA-Z+\\-\\.]*:)?\/{0,2}[0-9a-zA-Z;\/?:@&=+$\\.\\-_!~*\'()%]+)?(#[0-9a-zA-Z;\/?:@&=+$\\.\\-_!~*\'()%]+)?$/' + regEx: '/^(([a-zA-Z][0-9a-zA-Z+\\-\\.]*:)?\/{0,2}[0-9a-zA-Z;\/?:@&=+$\\.\\-_!~*\'()%]+)?(#[0-9a-zA-Z;\/?:@&=+$\\.\\-_!~*\'()%]+)?$/' ); - $this->setFormat($format); - $this->setContent($data); - $this->setLastChanged($lastChanged); + $this->setFormat(format: $format); + $this->setContent(data: $data); + $this->setLastChanged(dateTime: $lastChanged); $this->sets = new ArrayCollection(); } catch (ValidationFailedException $exception) { throw $exception; diff --git a/src/Entity/Set.php b/src/Entity/Set.php index 0551f63..c4fcf83 100644 --- a/src/Entity/Set.php +++ b/src/Entity/Set.php @@ -26,6 +26,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use OCC\OaiPmh2\Entity; +use OCC\OaiPmh2\Repository\SetRepository; use Symfony\Component\Validator\Exception\ValidationFailedException; /** @@ -34,9 +35,9 @@ use Symfony\Component\Validator\Exception\ValidationFailedException; * @author Sebastian Meyer * @package OAIPMH2 */ -#[ORM\Entity] +#[ORM\Entity(repositoryClass: SetRepository::class)] #[ORM\Table(name: 'sets')] -class Set extends Entity +final class Set extends Entity { /** * The unique set spec. @@ -60,9 +61,14 @@ class Set extends Entity /** * Collection of associated records. * - * @var Collection + * @var Collection */ - #[ORM\ManyToMany(targetEntity: Record::class, mappedBy: 'sets', fetch: 'EXTRA_LAZY')] + #[ORM\ManyToMany( + targetEntity: Record::class, + mappedBy: 'sets', + fetch: 'EXTRA_LAZY', + indexBy: 'identifier' + )] private Collection $records; /** @@ -74,9 +80,9 @@ class Set extends Entity */ public function addRecord(Record $record): void { - if (!$this->records->contains($record)) { - $this->records->add($record); - $record->addSet($this); + if (!$this->records->contains(element: $record)) { + $this->records->add(element: $record); + $record->addSet(set: $this); } } @@ -113,17 +119,20 @@ class Set extends Entity /** * Get a collection of associated records. * - * @return array The associated records + * @return Collection The associated records */ - public function getRecords(): array + public function getRecords(): Collection { - return $this->records->toArray(); + return $this->records; } /** * Whether this set has a description. * * @return bool TRUE if description exists, FALSE otherwise + * + * @psalm-assert-if-true string $this->description + * @psalm-assert-if-true string $this->getDescription() */ public function hasDescription(): bool { @@ -149,16 +158,16 @@ class Set extends Entity */ public function removeRecord(Record $record): void { - if ($this->records->contains($record)) { - $this->records->removeElement($record); - $record->removeSet($this); + if ($this->records->contains(element: $record)) { + $this->records->removeElement(element: $record); + $record->removeSet(set: $this); } } /** * Set the description for this set. * - * @param ?string $description The description + * @param ?string $description The description XML or NULL to unset * * @return void * @@ -169,7 +178,7 @@ class Set extends Entity if (isset($description)) { $description = trim($description); try { - $description = $this->validateXml($description); + $description = $this->validateXml(xml: $description); } catch (ValidationFailedException $exception) { throw $exception; } @@ -198,15 +207,15 @@ class Set extends Entity * * @throws ValidationFailedException */ - public function __construct(string $spec, ?string $name = null, string $description = null) + public function __construct(string $spec, ?string $name = null, ?string $description = null) { try { $this->spec = $this->validateRegEx( - $spec, - '/^([A-Za-z0-9\-_\.!~\*\'\(\)])+(:[A-Za-z0-9\-_\.!~\*\'\(\)]+)*$/' + string: $spec, + regEx: '/^([A-Za-z0-9\-_\.!~\*\'\(\)])+(:[A-Za-z0-9\-_\.!~\*\'\(\)]+)*$/' ); - $this->setName($name); - $this->setDescription($description); + $this->setName(name: $name); + $this->setDescription(description: $description); $this->records = new ArrayCollection(); } catch (ValidationFailedException $exception) { throw $exception; diff --git a/src/Entity/Token.php b/src/Entity/Token.php index 10f5313..e4a3dcc 100644 --- a/src/Entity/Token.php +++ b/src/Entity/Token.php @@ -27,17 +27,20 @@ use DateTime; use Doctrine\ORM\Mapping as ORM; use OCC\OaiPmh2\Configuration; use OCC\OaiPmh2\Entity; +use OCC\OaiPmh2\Repository\TokenRepository; /** * Doctrine/ORM Entity for resumption tokens. * * @author Sebastian Meyer * @package OAIPMH2 + * + * @psalm-import-type OaiRequestMetadata from \OCC\OaiPmh2\Middleware */ -#[ORM\Entity] +#[ORM\Entity(repositoryClass: TokenRepository::class)] #[ORM\Table(name: 'tokens')] #[ORM\Index(name: 'valid_until_idx', columns: ['valid_until'])] -class Token extends Entity +final class Token extends Entity { /** * The resumption token. @@ -77,11 +80,11 @@ class Token extends Entity /** * Get the query parameters. * - * @return array The query parameters + * @return OaiRequestMetadata The query parameters */ public function getParameters(): array { - /** @var array */ + /** @var OaiRequestMetadata */ return unserialize($this->parameters); } @@ -109,15 +112,15 @@ class Token extends Entity * Get new entity of resumption token. * * @param string $verb The verb for which the token is issued - * @param array $parameters The query parameters + * @param OaiRequestMetadata $parameters The query parameters */ public function __construct(string $verb, array $parameters) { $this->token = substr(md5(microtime()), 0, 8); $this->verb = $verb; $this->parameters = serialize($parameters); - $validity = new DateTime(); - $validity->add(new DateInterval('PT' . Configuration::getInstance()->tokenValid . 'S')); - $this->validUntil = $validity; + $validUntil = new DateTime(); + $validUntil->add(interval: new DateInterval(duration: 'PT' . Configuration::getInstance()->tokenValid . 'S')); + $this->validUntil = $validUntil; } } diff --git a/src/EntityManager.php b/src/EntityManager.php new file mode 100644 index 0000000..7844e43 --- /dev/null +++ b/src/EntityManager.php @@ -0,0 +1,471 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +declare(strict_types=1); + +namespace OCC\OaiPmh2; + +use DateTime; +use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\Schema\AbstractAsset; +use Doctrine\DBAL\Tools\DsnParser; +use Doctrine\ORM\AbstractQuery; +use Doctrine\ORM\Configuration as DoctrineConfiguration; +use Doctrine\ORM\Decorator\EntityManagerDecorator; +use Doctrine\ORM\EntityManager as DoctrineEntityManager; +use Doctrine\ORM\Mapping\Driver\AttributeDriver; +use Doctrine\ORM\Proxy\ProxyFactory; +use Doctrine\ORM\Query\Expr\Join; +use Doctrine\ORM\Tools\Pagination\Paginator; +use OCC\Basics\Traits\Singleton; +use OCC\OaiPmh2\Entity\Format; +use OCC\OaiPmh2\Entity\Record; +use OCC\OaiPmh2\Entity\Set; +use OCC\OaiPmh2\Entity\Token; +use Symfony\Component\Cache\Adapter\PhpFilesAdapter; +use Symfony\Component\Filesystem\Path; + +/** + * The Entity Manager controls all database shenanigans. + * + * @author Sebastian Meyer + * @package OAIPMH2 + * + * @mixin DoctrineEntityManager + * + * @psalm-import-type Params from DriverManager + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +final class EntityManager extends EntityManagerDecorator +{ + use Singleton; + + /** + * The database tables this class is allowed to handle. + * + * @var string[] + */ + private const TABLES = [ + 'formats', + 'records', + 'records_sets', + 'sets', + 'tokens' + ]; + + /** + * Add or update entity. + * + * @param Format|Record|Set|Token $entity The entity + * @param bool $bulkMode Should we operate in bulk mode (no flush)? + * + * @return void + */ + public function addOrUpdate(Format|Record|Set|Token $entity, bool $bulkMode = false): void + { + $this->getRepository(className: get_class($entity))->addOrUpdate(entity: $entity); + if (!$bulkMode) { + $this->flush(); + } + } + + /** + * Delete entity. + * + * @param Format|Record|Set|Token $entity The entity + * + * @return void + */ + public function delete(Format|Record|Set|Token $entity): void + { + $this->getRepository(className: get_class($entity))->delete(entity: $entity); + } + + /** + * Get the earliest datestamp of any record. + * + * @return string The earliest datestamp + */ + public function getEarliestDatestamp(): string + { + $timestamp = '0000-00-00T00:00:00Z'; + $dql = $this->createQueryBuilder(); + $dql->select(select: $dql->expr()->min('record.lastChanged')); + $dql->from(from: Record::class, alias: 'record'); + $query = $dql->getQuery()->enableResultCache(); + /** @var ?string $result */ + $result = $query->getOneOrNullResult(hydrationMode: AbstractQuery::HYDRATE_SCALAR_COLUMN); + return $result ?? $timestamp; + } + + /** + * Get reference to a single metadata format. + * + * @param string $prefix The metadata prefix + * + * @return ?Format The reference to the metadata format or NULL if invalid + */ + public function getMetadataFormat(string $prefix): ?Format + { + return $this->getReference(entityName: Format::class, id: $prefix); + } + + /** + * Get all available metadata formats (optionally for a given record identifier). + * + * @param ?string $recordIdentifier Optional record identifier + * + * @return ResultSet The metadata formats indexed by prefix + */ + public function getMetadataFormats(?string $recordIdentifier = null): ResultSet + { + $entities = []; + if ($recordIdentifier === null) { + $formats = $this->getRepository(className: Format::class)->findAll(); + } else { + $dql = $this->createQueryBuilder(); + $dql->select(select: 'record.format') + ->from(from: Record::class, alias: 'record') + ->where(predicates: $dql->expr()->eq('record.identifier', ':recordIdentifier')) + ->setParameter(key: 'recordIdentifier', value: $recordIdentifier); + $query = $dql->getQuery()->enableResultCache(); + /** @var Format[] */ + $formats = $query->getResult(hydrationMode: AbstractQuery::HYDRATE_OBJECT); + } + foreach ($formats as $format) { + $entities[$format->getPrefix()] = $format; + } + return new ResultSet(elements: $entities); + } + + /** + * Get a single record. + * + * @param string $identifier The record identifier + * @param string $format The metadata prefix + * + * @return ?Record The record or NULL if invalid + */ + public function getRecord(string $identifier, string $format): ?Record + { + return $this->getRepository(className: Record::class)->findOneBy( + criteria: [ + 'identifier' => $identifier, + 'format' => $this->getMetadataFormat(prefix: $format) + ] + ); + } + + /** + * Get list of records. + * + * @param string $verb The currently requested verb + * 'ListIdentifiers' or 'ListRecords' + * @param string $metadataPrefix The metadata prefix + * @param int $counter Counter for split result sets + * @param ?string $from The "from" datestamp + * @param ?string $until The "until" datestamp + * @param ?string $set The set spec + * + * @return ResultSet The records indexed by id and maybe a resumption token + */ + public function getRecords( + string $verb, + string $metadataPrefix, + int $counter = 0, + ?string $from = null, + ?string $until = null, + ?string $set = null + ): ResultSet { + $maxRecords = Configuration::getInstance()->maxRecords; + $cursor = $counter * $maxRecords; + + $dql = $this->createQueryBuilder(); + $dql->select(select: 'record') + ->from(from: Record::class, alias: 'record', indexBy: 'record.identifier') + ->where(predicates: $dql->expr()->eq('record.format', ':metadataPrefix')) + ->setParameter( + key: 'metadataPrefix', + value: $this->getMetadataFormat(prefix: $metadataPrefix) + ) + ->setFirstResult(firstResult: $cursor) + ->setMaxResults(maxResults: $maxRecords); + if (isset($from)) { + $dql->andWhere(where: $dql->expr()->gte('record.lastChanged', ':from')); + $dql->setParameter(key: 'from', value: new DateTime($from)); + } + if (isset($until)) { + $dql->andWhere(where: $dql->expr()->lte('record.lastChanged', ':until')); + $dql->setParameter(key: 'until', value: new DateTime($until)); + } + if (isset($set)) { + $dql->innerJoin( + join: Set::class, + alias: 'sets', + conditionType: Join::WITH, + condition: $dql->expr()->orX( + $dql->expr()->eq('sets.spec', ':setSpec'), + $dql->expr()->like('sets.spec', ':setLike') + ) + ); + $dql->setParameter(key: 'setSpec', value: $set); + $dql->setParameter(key: 'setLike', value: $set . ':%'); + } + $query = $dql->getQuery(); + /** @var array */ + $queryResult = $query->getResult(); + $result = new ResultSet(elements: $queryResult); + $paginator = new Paginator(query: $query, fetchJoinCollection: true); + if (count($paginator) > ($cursor + count($result))) { + $token = new Token( + verb: $verb, + parameters: [ + 'verb' => $verb, + 'identifier' => null, + 'metadataPrefix' => $metadataPrefix, + 'from' => $from, + 'until' => $until, + 'set' => $set, + 'resumptionToken' => null, + 'counter' => $counter + 1, + 'completeListSize' => count($paginator) + ] + ); + $this->persist(object: $token); + $this->flush(); + $result->setResumptionToken(token: $token); + } + return $result; + } + + /** + * Get resumption token. + * + * @param string $token The token + * @param string $verb The current verb to validate token + * + * @return ?Token The resumption token or NULL if invalid + */ + public function getResumptionToken(string $token, string $verb): ?Token + { + $resumptionToken = $this->getRepository(className: Token::class)->findOneBy( + criteria: [ + 'token' => $token, + 'verb' => $verb + ] + ); + if (isset($resumptionToken) && $resumptionToken->getValidUntil() < new DateTime()) { + $this->delete(entity: $resumptionToken); + return null; + } + return $resumptionToken; + } + + /** + * Get reference to a single set. + * + * @param string $spec The set spec + * + * @return ?Set The reference to the set or NULL if invalid + */ + public function getSet(string $spec): ?Set + { + return $this->getReference(entityName: Set::class, id: $spec); + } + + /** + * Get all available sets. + * + * @param int $counter Counter for split result sets + * + * @return ResultSet The sets indexed by spec + */ + public function getSets(int $counter = 0): ResultSet + { + $maxRecords = Configuration::getInstance()->maxRecords; + $cursor = $counter * $maxRecords; + + $dql = $this->createQueryBuilder(); + $dql->select(select: 'set') + ->from(from: Set::class, alias: 'set', indexBy: 'set.spec') + ->setFirstResult(firstResult: $cursor) + ->setMaxResults(maxResults: $maxRecords); + $query = $dql->getQuery()->enableResultCache(); + /** @var array */ + $queryResult = $query->getResult(hydrationMode: AbstractQuery::HYDRATE_OBJECT); + $result = new ResultSet(elements: $queryResult); + $paginator = new Paginator(query: $query); + if (count($paginator) > ($cursor + count($result))) { + $token = new Token( + verb: 'ListSets', + parameters: [ + 'verb' => 'ListSets', + 'identifier' => null, + 'metadataPrefix' => null, + 'from' => null, + 'until' => null, + 'set' => null, + 'resumptionToken' => null, + 'counter' => $counter + 1, + 'completeListSize' => count($paginator) + ] + ); + $this->persist(object: $token); + $this->flush(); + $result->setResumptionToken(token: $token); + } + return $result; + } + + /** + * Check if a record with the given identifier exists. + * + * @param string $identifier The record identifier + * + * @return bool Whether a record with the identifier exists + */ + public function isValidRecordIdentifier(string $identifier): bool + { + $records = $this->getRepository(className: Record::class)->findBy(criteria: ['identifier' => $identifier]); + return (bool) count($records) > 0; + } + + /** + * Prune deleted records. + * + * @return int The number of removed records + */ + public function pruneDeletedRecords(): int + { + $dql = $this->createQueryBuilder(); + $dql->delete(delete: Record::class, alias: 'record') + ->where(predicates: $dql->expr()->isNull('record.content')); + /** @var int */ + $deleted = $dql->getQuery()->execute(); + if ($deleted > 0) { + $this->pruneOrphanedSets(); + } + return $deleted; + } + + /** + * Prune expired resumption tokens. + * + * @return int The number of deleted tokens + */ + public function pruneExpiredTokens(): int + { + $dql = $this->createQueryBuilder(); + $dql->delete(delete: Token::class, alias: 'token') + ->where(predicates: $dql->expr()->lt('token.validUntil', new DateTime())); + /** @var int */ + return $dql->getQuery()->execute(); + } + + /** + * Prune orphan sets. + * + * @return int The number of removed sets + */ + public function pruneOrphanedSets(): int + { + $sets = $this->getRepository(className: Set::class)->findAll(); + $count = 0; + foreach ($sets as $set) { + if ($set->isEmpty()) { + $count += 1; + $this->remove(object: $set); + } + } + if ($count > 0) { + $this->flush(); + } + return $count; + } + + /** + * Instantiate new Doctrine entity manager and connect to database. + */ + private function __construct() + { + $config = new DoctrineConfiguration(); + $config->setAutoGenerateProxyClasses( + autoGenerate: ProxyFactory::AUTOGENERATE_NEVER + ); + $config->setMetadataCache( + cache: new PhpFilesAdapter( + namespace: 'Metadata', + directory: __DIR__ . '/../var/cache' + ) + ); + $config->setMetadataDriverImpl( + driverImpl: new AttributeDriver( + paths: [__DIR__ . '/Entity'] + ) + ); + $config->setProxyDir(dir: __DIR__ . '/../var/generated'); + $config->setProxyNamespace(ns: 'OCC\OaiPmh2\Entity\Proxy'); + $config->setQueryCache( + cache: new PhpFilesAdapter( + namespace: 'Query', + directory: __DIR__ . '/../var/cache' + ) + ); + $config->setResultCache( + cache: new PhpFilesAdapter( + namespace: 'Result', + directory: __DIR__ . '/../var/cache' + ) + ); + $config->setSchemaAssetsFilter( + schemaAssetsFilter: static function (string|AbstractAsset $assetName): bool { + if ($assetName instanceof AbstractAsset) { + $assetName = $assetName->getName(); + } + return in_array(needle: $assetName, haystack: self::TABLES, strict: true); + } + ); + + $baseDir = Path::canonicalize(path: __DIR__ . '/../'); + $dsn = str_replace( + search: '%BASEDIR%', + replace: $baseDir, + subject: Configuration::getInstance()->database + ); + $parser = new DsnParser( + schemeMapping: [ + 'mariadb' => 'pdo_mysql', + 'mssql' => 'pdo_sqlsrv', + 'mysql' => 'pdo_mysql', + 'oracle' => 'pdo_oci', + 'postgresql' => 'pdo_pgsql', + 'sqlite' => 'pdo_sqlite' + ] + ); + $conn = DriverManager::getConnection( + // Generic return type of DsnParser::parse() is not correctly recognized. + // phpcs:ignore + params: $parser->parse(dsn: $dsn), + config: $config + ); + + parent::__construct(new DoctrineEntityManager(conn: $conn, config: $config)); + } +} diff --git a/src/Middleware.php b/src/Middleware.php index 8ca7538..18994d7 100644 --- a/src/Middleware.php +++ b/src/Middleware.php @@ -22,7 +22,9 @@ declare(strict_types=1); namespace OCC\OaiPmh2; +use DOMElement; use GuzzleHttp\Psr7\Utils; +use OCC\OaiPmh2\Entity\Token; use OCC\OaiPmh2\Middleware\ErrorHandler; use OCC\PSR15\AbstractMiddleware; use Psr\Http\Message\ResponseInterface; @@ -33,13 +35,99 @@ use Psr\Http\Message\ServerRequestInterface; * * @author Sebastian Meyer * @package OAIPMH2 + * + * @psalm-type OaiRequestMetadata = array{ + * verb: string, + * identifier: ?string, + * metadataPrefix: ?string, + * from: ?string, + * until: ?string, + * set: ?string, + * resumptionToken: ?string, + * counter: int, + * completeListSize: int + * } */ abstract class Middleware extends AbstractMiddleware { + /** + * This holds the request metadata. + * + * @var OaiRequestMetadata + */ + protected array $arguments = [ + 'verb' => '', + 'identifier' => null, + 'metadataPrefix' => null, + 'from' => null, + 'until' => null, + 'set' => null, + 'resumptionToken' => null, + 'counter' => 0, + 'completeListSize' => 0 + ]; + + /** + * This holds the entity manager singleton. + */ + protected EntityManager $em; + /** * This holds the prepared response document. */ - protected Document $preparedResponse; + protected Response $preparedResponse; + + /** + * Add resumption token information to response document. + * + * @param DOMElement $node The DOM node to add the resumption token to + * @param ?Token $token The new resumption token or NULL if none + * + * @return void + */ + protected function addResumptionToken(DOMElement $node, ?Token $token): void + { + if (isset($token) || isset($this->arguments['resumptionToken'])) { + $resumptionToken = $this->preparedResponse->createElement(localName: 'resumptionToken'); + if (isset($token)) { + $resumptionToken->nodeValue = $token->getToken(); + $resumptionToken->setAttribute( + qualifiedName: 'expirationDate', + value: $token->getValidUntil()->format(format: 'Y-m-d\TH:i:s\Z') + ); + $this->arguments['completeListSize'] = $token->getParameters()['completeListSize']; + } + $resumptionToken->setAttribute( + qualifiedName: 'completeListSize', + value: (string) $this->arguments['completeListSize'] + ); + $resumptionToken->setAttribute( + qualifiedName: 'cursor', + value: (string) ($this->arguments['counter'] * Configuration::getInstance()->maxRecords) + ); + $node->appendChild(node: $resumptionToken); + } + } + + /** + * Check for resumption token and populate request arguments. + * + * @return void + */ + protected function checkResumptionToken(): void + { + if (isset($this->arguments['resumptionToken'])) { + $token = $this->em->getResumptionToken( + token: $this->arguments['resumptionToken'], + verb: $this->arguments['verb'] + ); + if (isset($token)) { + $this->arguments = array_merge($this->arguments, $token->getParameters()); + } else { + ErrorHandler::getInstance()->withError(errorCode: 'badResumptionToken'); + } + } + } /** * Prepare response document. @@ -59,7 +147,10 @@ abstract class Middleware extends AbstractMiddleware */ protected function processRequest(ServerRequestInterface $request): ServerRequestInterface { - $this->prepareResponse($request); + /** @var OaiRequestMetadata */ + $arguments = $request->getAttributes(); + $this->arguments = array_merge($this->arguments, $arguments); + $this->prepareResponse(request: $request); return $request; } @@ -73,18 +164,22 @@ abstract class Middleware extends AbstractMiddleware protected function processResponse(ResponseInterface $response): ResponseInterface { if (!ErrorHandler::getInstance()->hasErrors() && isset($this->preparedResponse)) { - $response = $response->withBody(Utils::streamFor((string) $this->preparedResponse)); + $response = $response->withBody( + body: Utils::streamFor( + resource: (string) $this->preparedResponse + ) + ); } return $response; } /** * The constructor must have the same signature for all derived classes, thus make it final. + * + * @see https://psalm.dev/229 */ final public function __construct() { - // Make constructor final to avoid issues in dispatcher. - // @see https://psalm.dev/229 + $this->em = EntityManager::getInstance(); } - } diff --git a/src/Middleware/Dispatcher.php b/src/Middleware/Dispatcher.php index 101272e..646ed80 100644 --- a/src/Middleware/Dispatcher.php +++ b/src/Middleware/Dispatcher.php @@ -22,6 +22,7 @@ declare(strict_types=1); namespace OCC\OaiPmh2\Middleware; +use OCC\OaiPmh2\EntityManager; use OCC\OaiPmh2\Middleware; use OCC\PSR15\AbstractMiddleware; use Psr\Http\Message\ResponseInterface; @@ -37,6 +38,8 @@ class Dispatcher extends AbstractMiddleware { /** * List of defined OAI-PMH parameters. + * + * @var string[] */ protected const OAI_PARAMS = [ 'verb', @@ -62,14 +65,14 @@ class Dispatcher extends AbstractMiddleware /** @var array */ $arguments = $request->getQueryParams(); } elseif ($request->getMethod() === 'POST') { - if ($request->getHeaderLine('Content-Type') === 'application/x-www-form-urlencoded') { + if ($request->getHeaderLine(name: 'Content-Type') === 'application/x-www-form-urlencoded') { /** @var array */ $arguments = (array) $request->getParsedBody(); } } - if ($this->validateArguments($arguments)) { + if ($this->validateArguments(arguments: $arguments)) { foreach ($arguments as $param => $value) { - $request = $request->withAttribute($param, $value); + $request = $request->withAttribute(name: $param, value: $value); } } return $request; @@ -84,17 +87,17 @@ class Dispatcher extends AbstractMiddleware */ protected function processRequest(ServerRequestInterface $request): ServerRequestInterface { - $request = $this->getRequestWithAttributes($request); + $request = $this->getRequestWithAttributes(request: $request); $errorHandler = ErrorHandler::getInstance(); if (!$errorHandler->hasErrors()) { /** @var string */ - $verb = $request->getAttribute('verb'); + $verb = $request->getAttribute(name: 'verb'); $middleware = __NAMESPACE__ . '\\' . $verb; - if (is_a($middleware, Middleware::class, true)) { - $this->requestHandler->queue->enqueue(new $middleware()); + if (is_a(object_or_class: $middleware, class: Middleware::class, allow_string: true)) { + $this->requestHandler->queue->enqueue(value: new $middleware()); } } - $this->requestHandler->queue->enqueue($errorHandler); + $this->requestHandler->queue->enqueue(value: $errorHandler); return $request; } @@ -110,7 +113,7 @@ class Dispatcher extends AbstractMiddleware // TODO: Add support for content compression // https://openarchives.org/OAI/openarchivesprotocol.html#ResponseCompression // https://github.com/middlewares/encoder - return $response->withHeader('Content-Type', 'text/xml'); + return $response->withHeader(name: 'Content-Type', value: 'text/xml'); } /** @@ -124,62 +127,166 @@ class Dispatcher extends AbstractMiddleware */ protected function validateArguments(array $arguments): bool { - $errorHandler = ErrorHandler::getInstance(); if ( count(array_diff(array_keys($arguments), self::OAI_PARAMS)) !== 0 or !isset($arguments['verb']) ) { - $errorHandler->withError('badArgument'); + ErrorHandler::getInstance()->withError(errorCode: 'badArgument'); } else { - switch ($arguments['verb']) { - case 'GetRecord': - if ( - count($arguments) !== 3 - or !isset($arguments['identifier']) - or !isset($arguments['metadataPrefix']) - ) { - $errorHandler->withError('badArgument'); - } - break; - case 'Identify': - if (count($arguments) !== 1) { - $errorHandler->withError('badArgument'); - } - break; - case 'ListIdentifiers': - case 'ListRecords': - if ( - isset($arguments['metadataPrefix']) - xor isset($arguments['resumptionToken']) - ) { - if ( - (isset($arguments['resumptionToken']) && count($arguments) !== 2) - or isset($arguments['identifier']) - ) { - $errorHandler->withError('badArgument'); - } - } else { - $errorHandler->withError('badArgument'); - } - break; - case 'ListMetadataFormats': - if (count($arguments) !== 1) { - if (!isset($arguments['identifier']) || count($arguments) !== 2) { - $errorHandler->withError('badArgument'); - } - } - break; - case 'ListSets': - if (count($arguments) !== 1) { - if (!isset($arguments['resumptionToken']) || count($arguments) !== 2) { - $errorHandler->withError('badArgument'); - } - } - break; - default: - $errorHandler->withError('badVerb'); + match ($arguments['verb']) { + 'GetRecord' => $this->validateGetRecord(arguments: $arguments), + 'Identify' => $this->validateIdentify(arguments: $arguments), + 'ListIdentifiers', 'ListRecords' => $this->validateListRecords(arguments: $arguments), + 'ListMetadataFormats' => $this->validateListFormats(arguments: $arguments), + 'ListSets' => $this->validateListSets(arguments: $arguments), + default => ErrorHandler::getInstance()->withError(errorCode: 'badVerb') + }; + if (!ErrorHandler::getInstance()->hasErrors()) { + $this->validateMetadataPrefix(prefix: $arguments['metadataPrefix'] ?? null); + $this->validateDateTime(datetime: $arguments['from'] ?? null); + $this->validateDateTime(datetime: $arguments['until'] ?? null); + $this->validateSet($arguments['set'] ?? null); + } + } + return !ErrorHandler::getInstance()->hasErrors(); + } + + /** + * Validate "from" and "until" argument. + * + * @param ?string $datetime The datetime string to validate or NULL if none + * + * @return void + */ + protected function validateDateTime(?string $datetime): void + { + if (isset($datetime)) { + $date = date_parse(datetime: $datetime); + if ($date['warning_count'] > 0 || $date['error_count'] > 0) { + ErrorHandler::getInstance()->withError(errorCode: 'badArgument'); + } + } + } + + /** + * Validate request arguments for verb GetRecord. + * + * @param string[] $arguments The request parameters + * + * @return void + */ + protected function validateGetRecord(array $arguments): void + { + if ( + count($arguments) !== 3 + or !isset($arguments['identifier']) + or !isset($arguments['metadataPrefix']) + ) { + ErrorHandler::getInstance()->withError(errorCode: 'badArgument'); + } + } + + /** + * Validate request arguments for verb Identify. + * + * @param string[] $arguments The request parameters + * + * @return void + */ + protected function validateIdentify(array $arguments): void + { + if (count($arguments) !== 1) { + ErrorHandler::getInstance()->withError(errorCode: 'badArgument'); + } + } + + /** + * Validate request arguments for verb ListMetadataFormats. + * + * @param string[] $arguments The request parameters + * + * @return void + */ + protected function validateListFormats(array $arguments): void + { + if (count($arguments) !== 1) { + if (!isset($arguments['identifier']) || count($arguments) !== 2) { + ErrorHandler::getInstance()->withError(errorCode: 'badArgument'); + } + } + } + + /** + * Validate request arguments for verbs ListIdentifiers and ListRecords. + * + * @param string[] $arguments The request parameters + * + * @return void + */ + protected function validateListRecords(array $arguments): void + { + if ( + isset($arguments['metadataPrefix']) + xor isset($arguments['resumptionToken']) + ) { + if ( + (isset($arguments['resumptionToken']) && count($arguments) !== 2) + or isset($arguments['identifier']) + ) { + ErrorHandler::getInstance()->withError(errorCode: 'badArgument'); + } + } else { + ErrorHandler::getInstance()->withError(errorCode: 'badArgument'); + } + } + + /** + * Validate request arguments for verb ListSets. + * + * @param string[] $arguments The request parameters + * + * @return void + */ + protected function validateListSets(array $arguments): void + { + if (count($arguments) !== 1) { + if (!isset($arguments['resumptionToken']) || count($arguments) !== 2) { + ErrorHandler::getInstance()->withError(errorCode: 'badArgument'); + } + } + } + + /** + * Validate "metadataPrefix" argument. + * + * @param ?string $prefix The metadata prefix + * + * @return void + */ + protected function validateMetadataPrefix(?string $prefix): void + { + if (isset($prefix)) { + $formats = EntityManager::getInstance()->getMetadataFormats(); + if (!$formats->containsKey(key: $prefix)) { + ErrorHandler::getInstance()->withError(errorCode: 'cannotDisseminateFormat'); + } + } + } + + /** + * Validate "set" argument. + * + * @param ?string $spec The set spec + * + * @return void + */ + protected function validateSet(?string $spec): void + { + if (isset($spec)) { + $sets = EntityManager::getInstance()->getSets(); + if (!$sets->containsKey(key: $spec)) { + ErrorHandler::getInstance()->withError(errorCode: 'badArgument'); } } - return !$errorHandler->hasErrors(); } } diff --git a/src/Middleware/ErrorHandler.php b/src/Middleware/ErrorHandler.php index 129ebf5..76cf08e 100644 --- a/src/Middleware/ErrorHandler.php +++ b/src/Middleware/ErrorHandler.php @@ -25,7 +25,7 @@ namespace OCC\OaiPmh2\Middleware; use DomainException; use GuzzleHttp\Psr7\Utils; use OCC\Basics\Traits\Singleton; -use OCC\OaiPmh2\Document; +use OCC\OaiPmh2\Response; use OCC\PSR15\AbstractMiddleware; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; @@ -48,7 +48,7 @@ class ErrorHandler extends AbstractMiddleware protected const OAI_ERRORS = [ 'badArgument' => 'The request includes illegal arguments, is missing required arguments, includes a repeated argument, or values for arguments have an illegal syntax.', 'badResumptionToken' => 'The value of the resumptionToken argument is invalid or expired.', - 'badVerb' => 'Value of the verb argument is not a legal OAI-PMH verb, the verb argument is missing, or the verb argument is repeated.', + 'badVerb' => 'The value of the verb argument is not a legal OAI-PMH verb, the verb argument is missing, or the verb argument is repeated.', 'cannotDisseminateFormat' => 'The metadata format identified by the value given for the metadataPrefix argument is not supported by the item or by the repository.', 'idDoesNotExist' => 'The value of the identifier argument is unknown or illegal in this repository.', 'noRecordsMatch' => 'The combination of the values of the from, until, set and metadataPrefix arguments results in an empty list.', @@ -70,12 +70,19 @@ class ErrorHandler extends AbstractMiddleware */ protected function getResponseBody(): StreamInterface { - $document = new Document($this->requestHandler->request); + $response = new Response(serverRequest: $this->requestHandler->request); foreach (array_unique($this->errors) as $errorCode) { - $error = $document->createElement('error', self::OAI_ERRORS[$errorCode], true); - $error->setAttribute('code', $errorCode); + $error = $response->createElement( + localName: 'error', + value: self::OAI_ERRORS[$errorCode], + appendToRoot: true + ); + $error->setAttribute( + qualifiedName: 'code', + value: $errorCode + ); } - return Utils::streamFor((string) $document); + return Utils::streamFor(resource: (string) $response); } /** @@ -98,7 +105,7 @@ class ErrorHandler extends AbstractMiddleware protected function processResponse(ResponseInterface $response): ResponseInterface { if ($this->hasErrors()) { - $response = $response->withBody($this->getResponseBody()); + $response = $response->withBody(body: $this->getResponseBody()); } return $response; } @@ -118,11 +125,11 @@ class ErrorHandler extends AbstractMiddleware $this->errors[] = $errorCode; } else { throw new DomainException( - sprintf( - 'Valid OAI-PMH error code expected, "%s" given.', - $errorCode + message: sprintf( + format: 'Valid OAI-PMH error code expected, "%s" given.', + values: $errorCode ), - 500 + code: 500 ); } return $this; diff --git a/src/Middleware/GetRecord.php b/src/Middleware/GetRecord.php index 04f841d..c0d6046 100644 --- a/src/Middleware/GetRecord.php +++ b/src/Middleware/GetRecord.php @@ -22,14 +22,13 @@ declare(strict_types=1); namespace OCC\OaiPmh2\Middleware; -use OCC\OaiPmh2\Database; -use OCC\OaiPmh2\Document; -use OCC\OaiPmh2\Entity\Format; use OCC\OaiPmh2\Middleware; +use OCC\OaiPmh2\Response; use Psr\Http\Message\ServerRequestInterface; /** * Process the "GetRecord" request. + * * @see https://www.openarchives.org/OAI/openarchivesprotocol.html#GetRecord * * @author Sebastian Meyer @@ -46,54 +45,67 @@ class GetRecord extends Middleware */ protected function prepareResponse(ServerRequestInterface $request): void { - /** @var array */ - $params = $request->getAttributes(); - /** @var Format */ - $format = Database::getInstance()->getEntityManager()->getReference(Format::class, $params['metadataPrefix']); - $oaiRecord = Database::getInstance()->getRecord($params['identifier'], $format); + $oaiRecord = $this->em->getRecord( + identifier: (string) $this->arguments['identifier'], + format: (string) $this->arguments['metadataPrefix'] + ); if (!isset($oaiRecord)) { - if (Database::getInstance()->idDoesExist($params['identifier'])) { - ErrorHandler::getInstance()->withError('cannotDisseminateFormat'); + if ($this->em->isValidRecordIdentifier(identifier: (string) $this->arguments['identifier'])) { + ErrorHandler::getInstance()->withError(errorCode: 'cannotDisseminateFormat'); } else { - ErrorHandler::getInstance()->withError('idDoesNotExist'); + ErrorHandler::getInstance()->withError(errorCode: 'idDoesNotExist'); } return; - } else { - $oaiRecordContent = $oaiRecord->getContent(); } - $document = new Document($request); - $getRecord = $document->createElement('GetRecord', '', true); + $response = new Response(serverRequest: $request); + $getRecord = $response->createElement( + localName: 'GetRecord', + value: '', + appendToRoot: true + ); - $record = $document->createElement('record'); - $getRecord->appendChild($record); + $record = $response->createElement(localName: 'record'); + $getRecord->appendChild(node: $record); - $header = $document->createElement('header'); - if (!isset($oaiRecordContent)) { - $header->setAttribute('status', 'deleted'); + $header = $response->createElement(localName: 'header'); + if (!$oaiRecord->hasContent()) { + $header->setAttribute( + qualifiedName: 'status', + value: 'deleted' + ); } - $record->appendChild($header); + $record->appendChild(node: $header); - $identifier = $document->createElement('identifier', $oaiRecord->getIdentifier()); - $header->appendChild($identifier); + $identifier = $response->createElement( + localName: 'identifier', + value: $oaiRecord->getIdentifier() + ); + $header->appendChild(node: $identifier); - $datestamp = $document->createElement('datestamp', $oaiRecord->getLastChanged()->format('Y-m-d\TH:i:s\Z')); - $header->appendChild($datestamp); + $datestamp = $response->createElement( + localName: 'datestamp', + value: $oaiRecord->getLastChanged()->format(format: 'Y-m-d\TH:i:s\Z') + ); + $header->appendChild(node: $datestamp); foreach ($oaiRecord->getSets() as $set) { - $setSpec = $document->createElement('setSpec', $set->getName()); - $header->appendChild($setSpec); + $setSpec = $response->createElement( + localName: 'setSpec', + value: $set->getName() + ); + $header->appendChild(node: $setSpec); } - if (isset($oaiRecordContent)) { - $metadata = $document->createElement('metadata'); - $record->appendChild($metadata); + if ($oaiRecord->hasContent()) { + $metadata = $response->createElement(localName: 'metadata'); + $record->appendChild(node: $metadata); - $data = $document->importData($oaiRecordContent); - $metadata->appendChild($data); + $data = $response->importData(data: $oaiRecord->getContent()); + $metadata->appendChild(node: $data); } - $this->preparedResponse = $document; + $this->preparedResponse = $response; } } diff --git a/src/Middleware/Identify.php b/src/Middleware/Identify.php index 2cff920..14dca53 100644 --- a/src/Middleware/Identify.php +++ b/src/Middleware/Identify.php @@ -24,13 +24,13 @@ namespace OCC\OaiPmh2\Middleware; use GuzzleHttp\Psr7\Uri; use OCC\OaiPmh2\Configuration; -use OCC\OaiPmh2\Database; -use OCC\OaiPmh2\Document; use OCC\OaiPmh2\Middleware; +use OCC\OaiPmh2\Response; use Psr\Http\Message\ServerRequestInterface; /** * Process the "Identify" request. + * * @see https://www.openarchives.org/OAI/openarchivesprotocol.html#Identify * * @author Sebastian Meyer @@ -47,47 +47,75 @@ class Identify extends Middleware */ protected function prepareResponse(ServerRequestInterface $request): void { - $document = new Document($request); - $identify = $document->createElement('Identify', '', true); + $response = new Response(serverRequest: $request); + $identify = $response->createElement( + localName: 'Identify', + value: '', + appendToRoot: true + ); - $name = Configuration::getInstance()->repositoryName; - $repositoryName = $document->createElement('repositoryName', $name); - $identify->appendChild($repositoryName); + $repositoryName = $response->createElement( + localName: 'repositoryName', + value: Configuration::getInstance()->repositoryName + ); + $identify->appendChild(node: $repositoryName); $uri = Uri::composeComponents( - $request->getUri()->getScheme(), - $request->getUri()->getAuthority(), - $request->getUri()->getPath(), - null, - null + scheme: $request->getUri()->getScheme(), + authority: $request->getUri()->getAuthority(), + path: $request->getUri()->getPath(), + query: null, + fragment: null ); - $baseURL = $document->createElement('baseURL', $uri); - $identify->appendChild($baseURL); + $baseURL = $response->createElement( + localName: 'baseURL', + value: $uri + ); + $identify->appendChild(node: $baseURL); - $protocolVersion = $document->createElement('protocolVersion', '2.0'); - $identify->appendChild($protocolVersion); + $protocolVersion = $response->createElement( + localName: 'protocolVersion', + value: '2.0' + ); + $identify->appendChild(node: $protocolVersion); - $email = Configuration::getInstance()->adminEmail; - $adminEmail = $document->createElement('adminEmail', $email); - $identify->appendChild($adminEmail); + $adminEmail = $response->createElement( + localName: 'adminEmail', + value: Configuration::getInstance()->adminEmail + ); + $identify->appendChild(node: $adminEmail); - $datestamp = Database::getInstance()->getEarliestDatestamp(); - $earliestDatestamp = $document->createElement('earliestDatestamp', $datestamp); - $identify->appendChild($earliestDatestamp); + $earliestDatestamp = $response->createElement( + localName: 'earliestDatestamp', + value: $this->em->getEarliestDatestamp() + ); + $identify->appendChild(node: $earliestDatestamp); - $deletedRecord = $document->createElement('deletedRecord', 'transient'); - $identify->appendChild($deletedRecord); + $deletedRecord = $response->createElement( + localName: 'deletedRecord', + value: Configuration::getInstance()->deletedRecords + ); + $identify->appendChild(node: $deletedRecord); - $granularity = $document->createElement('granularity', 'YYYY-MM-DDThh:mm:ssZ'); - $identify->appendChild($granularity); + $granularity = $response->createElement( + localName: 'granularity', + value: 'YYYY-MM-DDThh:mm:ssZ' + ); + $identify->appendChild(node: $granularity); // TODO: Implement explicit content compression support. - // $compressionDeflate = $document->createElement('compression', 'deflate'); - // $identify->appendChild($compressionDeflate); + // $compressionDeflate = $response->createElement( + // localName: 'compression', + // value: 'deflate' + // ); + // $identify->appendChild(node: $compressionDeflate); - // $compressionGzip = $document->createElement('compression', 'gzip'); - // $identify->appendChild($compressionGzip); + // $compressionGzip = $response->createElement( + // localName: 'compression', + // value: 'gzip' + // ); + // $identify->appendChild(node: $compressionGzip); - $this->preparedResponse = $document; + $this->preparedResponse = $response; } } diff --git a/src/Middleware/ListIdentifiers.php b/src/Middleware/ListIdentifiers.php index 93ebdad..66aed60 100644 --- a/src/Middleware/ListIdentifiers.php +++ b/src/Middleware/ListIdentifiers.php @@ -22,16 +22,13 @@ declare(strict_types=1); namespace OCC\OaiPmh2\Middleware; -use DateTime; -use OCC\OaiPmh2\Configuration; -use OCC\OaiPmh2\Database; -use OCC\OaiPmh2\Document; -use OCC\OaiPmh2\Entity\Record; use OCC\OaiPmh2\Middleware; +use OCC\OaiPmh2\Response; use Psr\Http\Message\ServerRequestInterface; /** * Process the "ListIdentifiers" request. + * * @see https://www.openarchives.org/OAI/openarchivesprotocol.html#ListIdentifiers * * @author Sebastian Meyer @@ -40,7 +37,7 @@ use Psr\Http\Message\ServerRequestInterface; class ListIdentifiers extends Middleware { /** - * Prepare the response body for verb "ListIdentifiers" and "ListRecords". + * Prepare the response body for verbs "ListIdentifiers" and "ListRecords". * * @param ServerRequestInterface $request The incoming request * @@ -48,127 +45,78 @@ class ListIdentifiers extends Middleware */ protected function prepareResponse(ServerRequestInterface $request): void { - $counter = 0; - $completeListSize = 0; - $maxRecords = Configuration::getInstance()->maxRecords; + $this->checkResumptionToken(); - /** @var array */ - $params = $request->getAttributes(); - $verb = $params['verb']; - $metadataPrefix = $params['metadataPrefix'] ?? ''; - $from = $params['from'] ?? null; - $until = $params['until'] ?? null; - $set = $params['set'] ?? null; - $resumptionToken = $params['resumptionToken'] ?? null; - - if (isset($resumptionToken)) { - $oldToken = Database::getInstance()->getResumptionToken($resumptionToken, $verb); - if (!isset($oldToken)) { - ErrorHandler::getInstance()->withError('badResumptionToken'); - return; - } else { - foreach ($oldToken->getParameters() as $key => $value) { - $$key = $value; - } - } - } - $prefixes = Database::getInstance()->getMetadataFormats()->getQueryResult(); - if (!array_key_exists($metadataPrefix, $prefixes)) { - ErrorHandler::getInstance()->withError('cannotDisseminateFormat'); - return; - } - if (isset($from)) { - $from = new DateTime($from); - } - if (isset($until)) { - $until = new DateTime($until); - } - if (isset($set)) { - $sets = Database::getInstance()->getSets()->getQueryResult(); - if (!array_key_exists($set, $sets)) { - ErrorHandler::getInstance()->withError('noSetHierarchy'); - return; - } - $set = $sets[$set]; - } - - $records = Database::getInstance()->getRecords( - $verb, - $prefixes[$metadataPrefix], - $counter, - $from, - $until, - $set + $records = $this->em->getRecords( + verb: $this->arguments['verb'], + metadataPrefix: (string) $this->arguments['metadataPrefix'], + counter: $this->arguments['counter'], + from: $this->arguments['from'], + until: $this->arguments['until'], + set: $this->arguments['set'] ); - $newToken = $records->getResumptionToken(); if (count($records) === 0) { - ErrorHandler::getInstance()->withError('noRecordsMatch'); + ErrorHandler::getInstance()->withError(errorCode: 'noRecordsMatch'); return; - } elseif (isset($newToken)) { - $completeListSize = $newToken->getParameters()['completeListSize']; } - $document = new Document($request); - $list = $document->createElement($verb, '', true); + $response = new Response(serverRequest: $request); + $list = $response->createElement( + localName: $this->arguments['verb'], + value: '', + appendToRoot: true + ); + $baseNode = $list; - /** @var Record $oaiRecord */ foreach ($records as $oaiRecord) { - if ($verb === 'ListIdentifiers') { - $baseNode = $list; - } else { - $record = $document->createElement('record'); - $list->appendChild($record); + if ($this->arguments['verb'] === 'ListRecords') { + $record = $response->createElement(localName: 'record'); + $list->appendChild(node: $record); $baseNode = $record; } - $header = $document->createElement('header'); - if (!$oaiRecord->hasContent()) { - $header->setAttribute('status', 'deleted'); - } - $baseNode->appendChild($header); + $header = $response->createElement(localName: 'header'); + $baseNode->appendChild(node: $header); - $identifier = $document->createElement('identifier', $oaiRecord->getIdentifier()); - $header->appendChild($identifier); + $identifier = $response->createElement( + localName: 'identifier', + value: $oaiRecord->getIdentifier() + ); + $header->appendChild(node: $identifier); - $datestamp = $document->createElement('datestamp', $oaiRecord->getLastChanged()->format('Y-m-d\TH:i:s\Z')); - $header->appendChild($datestamp); + $datestamp = $response->createElement( + localName: 'datestamp', + value: $oaiRecord->getLastChanged()->format(format: 'Y-m-d\TH:i:s\Z') + ); + $header->appendChild(node: $datestamp); foreach ($oaiRecord->getSets() as $oaiSet) { - $setSpec = $document->createElement('setSpec', $oaiSet->getName()); - $header->appendChild($setSpec); - } - - if ($verb === 'ListRecords' && $oaiRecord->hasContent()) { - $metadata = $document->createElement('metadata'); - $baseNode->appendChild($metadata); - - /** @var string */ - $content = $oaiRecord->getContent(); - $data = $document->importData($content); - $metadata->appendChild($data); - } - } - - if (isset($oldToken) || isset($newToken)) { - $resumptionToken = $document->createElement('resumptionToken'); - $list->appendChild($resumptionToken); - if (isset($newToken)) { - $resumptionToken->nodeValue = $newToken->getToken(); - $resumptionToken->setAttribute( - 'expirationDate', - $newToken->getValidUntil()->format('Y-m-d\TH:i:s\Z') + $setSpec = $response->createElement( + localName: 'setSpec', + value: $oaiSet->getName() ); + $header->appendChild(node: $setSpec); + } + + if (!$oaiRecord->hasContent()) { + $header->setAttribute( + qualifiedName: 'status', + value: 'deleted' + ); + } elseif ($this->arguments['verb'] === 'ListRecords') { + $metadata = $response->createElement(localName: 'metadata'); + $baseNode->appendChild(node: $metadata); + + $data = $response->importData(data: $oaiRecord->getContent()); + $metadata->appendChild(node: $data); } - $resumptionToken->setAttribute( - 'completeListSize', - (string) $completeListSize - ); - $resumptionToken->setAttribute( - 'cursor', - (string) ($counter * $maxRecords) - ); } - $this->preparedResponse = $document; + $this->preparedResponse = $response; + + $this->addResumptionToken( + node: $list, + token: $records->getResumptionToken() ?? null + ); } } diff --git a/src/Middleware/ListMetadataFormats.php b/src/Middleware/ListMetadataFormats.php index 091dbb4..479f6c5 100644 --- a/src/Middleware/ListMetadataFormats.php +++ b/src/Middleware/ListMetadataFormats.php @@ -22,14 +22,13 @@ declare(strict_types=1); namespace OCC\OaiPmh2\Middleware; -use OCC\OaiPmh2\Database; -use OCC\OaiPmh2\Document; -use OCC\OaiPmh2\Entity\Format; use OCC\OaiPmh2\Middleware; +use OCC\OaiPmh2\Response; use Psr\Http\Message\ServerRequestInterface; /** * Process the "ListMetadataFormats" request. + * * @see https://www.openarchives.org/OAI/openarchivesprotocol.html#ListMetadataFormats * * @author Sebastian Meyer @@ -46,37 +45,50 @@ class ListMetadataFormats extends Middleware */ protected function prepareResponse(ServerRequestInterface $request): void { - /** @var ?string */ - $identifier = $request->getAttribute('identifier'); - $formats = Database::getInstance()->getMetadataFormats($identifier); + $formats = $this->em->getMetadataFormats(recordIdentifier: $this->arguments['identifier']); if (count($formats) === 0) { - if (!isset($identifier) || Database::getInstance()->idDoesExist($identifier)) { - ErrorHandler::getInstance()->withError('noMetadataFormats'); + if ( + !isset($this->arguments['identifier']) + || $this->em->isValidRecordIdentifier(identifier: $this->arguments['identifier']) + ) { + ErrorHandler::getInstance()->withError(errorCode: 'noMetadataFormats'); } else { - ErrorHandler::getInstance()->withError('idDoesNotExist'); + ErrorHandler::getInstance()->withError(errorCode: 'idDoesNotExist'); } return; } - $document = new Document($request); - $listMetadataFormats = $document->createElement('ListMetadataFormats', '', true); + $response = new Response(serverRequest: $request); + $listMetadataFormats = $response->createElement( + localName: 'ListMetadataFormats', + value: '', + appendToRoot: true + ); - /** @var Format $oaiFormat */ foreach ($formats as $oaiFormat) { - $metadataFormat = $document->createElement('metadataFormat'); - $listMetadataFormats->appendChild($metadataFormat); + $metadataFormat = $response->createElement(localName: 'metadataFormat'); + $listMetadataFormats->appendChild(node: $metadataFormat); - $metadataPrefix = $document->createElement('metadataPrefix', $oaiFormat->getPrefix()); - $metadataFormat->appendChild($metadataPrefix); + $metadataPrefix = $response->createElement( + localName: 'metadataPrefix', + value: $oaiFormat->getPrefix() + ); + $metadataFormat->appendChild(node: $metadataPrefix); - $schema = $document->createElement('schema', $oaiFormat->getSchema()); - $metadataFormat->appendChild($schema); + $schema = $response->createElement( + localName: 'schema', + value: $oaiFormat->getSchema() + ); + $metadataFormat->appendChild(node: $schema); - $metadataNamespace = $document->createElement('metadataNamespace', $oaiFormat->getNamespace()); - $metadataFormat->appendChild($metadataNamespace); + $metadataNamespace = $response->createElement( + localName: 'metadataNamespace', + value: $oaiFormat->getNamespace() + ); + $metadataFormat->appendChild(node: $metadataNamespace); } - $this->preparedResponse = $document; + $this->preparedResponse = $response; } } diff --git a/src/Middleware/ListRecords.php b/src/Middleware/ListRecords.php index 6cd01ec..1e601f2 100644 --- a/src/Middleware/ListRecords.php +++ b/src/Middleware/ListRecords.php @@ -24,6 +24,7 @@ namespace OCC\OaiPmh2\Middleware; /** * Process the "ListRecords" request. + * * @see https://www.openarchives.org/OAI/openarchivesprotocol.html#ListRecords * * @author Sebastian Meyer @@ -34,6 +35,6 @@ class ListRecords extends ListIdentifiers /** * "ListIdentifiers" and "ListRecords" are practically identical except the * former returns the header information only while the latter also returns - * the records' data. + * the records' data. Hence this is just a class alias. */ } diff --git a/src/Middleware/ListSets.php b/src/Middleware/ListSets.php index 0d2ba2f..2f40a2a 100644 --- a/src/Middleware/ListSets.php +++ b/src/Middleware/ListSets.php @@ -22,21 +22,17 @@ declare(strict_types=1); namespace OCC\OaiPmh2\Middleware; -use OCC\OaiPmh2\Configuration; -use OCC\OaiPmh2\Database; -use OCC\OaiPmh2\Document; -use OCC\OaiPmh2\Entity\Set; use OCC\OaiPmh2\Middleware; +use OCC\OaiPmh2\Response; use Psr\Http\Message\ServerRequestInterface; /** * Process the "ListSets" request. + * * @see https://openarchives.org/OAI/openarchivesprotocol.html#ListSets * * @author Sebastian Meyer * @package OAIPMH2 - * - * @template Sets of array */ class ListSets extends Middleware { @@ -49,78 +45,52 @@ class ListSets extends Middleware */ protected function prepareResponse(ServerRequestInterface $request): void { - $counter = 0; - $completeListSize = 0; - $maxRecords = Configuration::getInstance()->maxRecords; + $this->checkResumptionToken(); - /** @var ?string */ - $token = $request->getAttribute('resumptionToken'); - if (isset($token)) { - $oldToken = Database::getInstance()->getResumptionToken($token, 'ListSets'); - if (!isset($oldToken)) { - ErrorHandler::getInstance()->withError('badResumptionToken'); - return; - } else { - foreach ($oldToken->getParameters() as $key => $value) { - $$key = $value; - } - } - } + $sets = $this->em->getSets(counter: $this->arguments['counter']); - $sets = Database::getInstance()->getSets($counter); - $newToken = $sets->getResumptionToken(); if (count($sets) === 0) { - ErrorHandler::getInstance()->withError('noSetHierarchy'); + ErrorHandler::getInstance()->withError(errorCode: 'noSetHierarchy'); return; - } elseif (isset($newToken)) { - $completeListSize = $newToken->getParameters()['completeListSize']; } - $document = new Document($request); - $list = $document->createElement('ListSets', '', true); + $response = new Response(serverRequest: $request); + $list = $response->createElement( + localName: 'ListSets', + value: '', + appendToRoot: true + ); - /** @var Set $oaiSet */ foreach ($sets as $oaiSet) { - $set = $document->createElement('set'); - $list->appendChild($set); + $set = $response->createElement(localName: 'set'); + $list->appendChild(node: $set); - $setSpec = $document->createElement('setSpec', $oaiSet->getSpec()); - $set->appendChild($setSpec); + $setSpec = $response->createElement( + localName: 'setSpec', + value: $oaiSet->getSpec() + ); + $set->appendChild(node: $setSpec); - $setName = $document->createElement('setName', $oaiSet->getName()); - $set->appendChild($setName); + $setName = $response->createElement( + localName: 'setName', + value: $oaiSet->getName() + ); + $set->appendChild(node: $setName); if ($oaiSet->hasDescription()) { - $setDescription = $document->createElement('setDescription'); - $set->appendChild($setDescription); + $setDescription = $response->createElement(localName: 'setDescription'); + $set->appendChild(node: $setDescription); - /** @var string */ - $description = $oaiSet->getDescription(); - $data = $document->importData($description); - $setDescription->appendChild($data); + $data = $response->importData(data: $oaiSet->getDescription()); + $setDescription->appendChild(node: $data); } } - if (isset($oldToken) || isset($newToken)) { - $resumptionToken = $document->createElement('resumptionToken'); - $list->appendChild($resumptionToken); - if (isset($newToken)) { - $resumptionToken->nodeValue = $newToken->getToken(); - $resumptionToken->setAttribute( - 'expirationDate', - $newToken->getValidUntil()->format('Y-m-d\TH:i:s\Z') - ); - } - $resumptionToken->setAttribute( - 'completeListSize', - (string) $completeListSize - ); - $resumptionToken->setAttribute( - 'cursor', - (string) ($counter * $maxRecords) - ); - } + $this->preparedResponse = $response; - $this->preparedResponse = $document; + $this->addResumptionToken( + node: $list, + token: $sets->getResumptionToken() ?? null + ); } } diff --git a/src/Repository/FormatRepository.php b/src/Repository/FormatRepository.php new file mode 100644 index 0000000..7b96dff --- /dev/null +++ b/src/Repository/FormatRepository.php @@ -0,0 +1,72 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +declare(strict_types=1); + +namespace OCC\OaiPmh2\Repository; + +use Doctrine\ORM\EntityRepository; +use OCC\OaiPmh2\Entity\Format; +use OCC\OaiPmh2\EntityManager; + +/** + * Doctrine/ORM Repository for formats. + * + * @author Sebastian Meyer + * @package OAIPMH2 + * + * @extends EntityRepository + */ +final class FormatRepository extends EntityRepository +{ + /** + * Add or update metadata format. + * + * @param Format $entity The metadata format + * + * @return void + */ + public function addOrUpdate(Format $entity): void + { + $oldFormat = $this->find(id: $entity->getPrefix()); + if (isset($oldFormat)) { + $oldFormat->setNamespace(namespace: $entity->getNamespace()); + $oldFormat->setSchema(schema: $entity->getSchema()); + } else { + $this->getEntityManager()->persist(object: $entity); + } + } + + /** + * Delete metadata format and all associated records. + * + * @param Format $entity The metadata format + * + * @return void + */ + public function delete(Format $entity): void + { + /** @var EntityManager */ + $entityManager = $this->getEntityManager(); + $entityManager->remove(object: $entity); + $entityManager->flush(); + $entityManager->pruneOrphanedSets(); + } +} diff --git a/src/Repository/RecordRepository.php b/src/Repository/RecordRepository.php new file mode 100644 index 0000000..c2ecc6f --- /dev/null +++ b/src/Repository/RecordRepository.php @@ -0,0 +1,103 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +declare(strict_types=1); + +namespace OCC\OaiPmh2\Repository; + +use DateTime; +use Doctrine\ORM\EntityRepository; +use OCC\OaiPmh2\Configuration; +use OCC\OaiPmh2\Entity\Record; +use OCC\OaiPmh2\EntityManager; + +/** + * Doctrine/ORM Repository for records. + * + * @author Sebastian Meyer + * @package OAIPMH2 + * + * @extends EntityRepository + */ +final class RecordRepository extends EntityRepository +{ + /** + * Add or update record. + * + * @param Record $entity The record + * + * @return void + */ + public function addOrUpdate(Record $entity): void + { + /** @var EntityManager */ + $entityManager = $this->getEntityManager(); + $oldRecord = $this->find( + id: [ + 'identifier' => $entity->getIdentifier(), + 'format' => $entity->getFormat() + ] + ); + if (isset($oldRecord)) { + if ($entity->hasContent() || Configuration::getInstance()->deletedRecords !== 'no') { + $oldRecord->setContent(data: $entity->getContent(), validate: false); + $oldRecord->setLastChanged(dateTime: $entity->getLastChanged()); + $newSets = $entity->getSets()->toArray(); + $oldSets = $oldRecord->getSets()->toArray(); + // Add new sets. + foreach (array_diff(array: $newSets, arrays: $oldSets) as $newSet) { + $oldRecord->addSet(set: $newSet); + } + // Remove old sets. + foreach (array_diff(array: $oldSets, arrays: $newSets) as $oldSet) { + $oldRecord->removeSet(set: $oldSet); + } + } else { + $entityManager->remove(object: $oldRecord); + } + } else { + if ($entity->hasContent() || Configuration::getInstance()->deletedRecords !== 'no') { + $entityManager->persist(object: $entity); + } + } + } + + /** + * Delete a record. + * + * @param Record $entity The record + * + * @return void + */ + public function delete(Record $entity): void + { + /** @var EntityManager */ + $entityManager = $this->getEntityManager(); + if (Configuration::getInstance()->deletedRecords === 'no') { + $entityManager->remove(object: $entity); + $entityManager->flush(); + $entityManager->pruneOrphanedSets(); + } else { + $entity->setContent(); + $entity->setLastChanged(dateTime: new DateTime()); + $entityManager->flush(); + } + } +} diff --git a/src/Repository/SetRepository.php b/src/Repository/SetRepository.php new file mode 100644 index 0000000..be74432 --- /dev/null +++ b/src/Repository/SetRepository.php @@ -0,0 +1,71 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +declare(strict_types=1); + +namespace OCC\OaiPmh2\Repository; + +use Doctrine\ORM\EntityRepository; +use OCC\OaiPmh2\Configuration; +use OCC\OaiPmh2\Entity\Set; +use OCC\OaiPmh2\ResultSet; + +/** + * Doctrine/ORM Repository for sets. + * + * @author Sebastian Meyer + * @package OAIPMH2 + * + * @extends EntityRepository + */ +final class SetRepository extends EntityRepository +{ + /** + * Add or update set. + * + * @param Set $entity The set + * + * @return void + */ + public function addOrUpdate(Set $entity): void + { + $oldSet = $this->find(id: $entity->getSpec()); + if (isset($oldSet)) { + $oldSet->setName(name: $entity->getName()); + $oldSet->setDescription(description: $entity->getDescription()); + } else { + $this->getEntityManager()->persist(object: $entity); + } + } + + /** + * Delete set. + * + * @param Set $entity The set + * + * @return void + */ + public function delete(Set $entity): void + { + $entityManager = $this->getEntityManager(); + $entityManager->remove(object: $entity); + $entityManager->flush(); + } +} diff --git a/src/Repository/TokenRepository.php b/src/Repository/TokenRepository.php new file mode 100644 index 0000000..129d255 --- /dev/null +++ b/src/Repository/TokenRepository.php @@ -0,0 +1,64 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +declare(strict_types=1); + +namespace OCC\OaiPmh2\Repository; + +use DateTime; +use Doctrine\ORM\EntityRepository; +use OCC\OaiPmh2\Entity\Token; + +/** + * Doctrine/ORM Repository for resumption tokens. + * + * @author Sebastian Meyer + * @package OAIPMH2 + * + * @extends EntityRepository + */ +final class TokenRepository extends EntityRepository +{ + /** + * Add resumption token. + * + * @param Token $entity The resumption token + * + * @return void + */ + public function addOrUpdate(Token $entity): void + { + $this->getEntityManager()->persist(object: $entity); + } + + /** + * Delete resumption token. + * + * @param Token $entity The resumption token + * + * @return void + */ + public function delete(Token $entity): void + { + $entityManager = $this->getEntityManager(); + $entityManager->remove(object: $entity); + $entityManager->flush(); + } +} diff --git a/src/Document.php b/src/Response.php similarity index 62% rename from src/Document.php rename to src/Response.php index d504207..ac4e68f 100644 --- a/src/Document.php +++ b/src/Response.php @@ -35,22 +35,17 @@ use Psr\Http\Message\ServerRequestInterface; * @author Sebastian Meyer * @package OAIPMH2 */ -class Document +final class Response { /** * This holds the DOMDocument of the OAI-PMH XML response. */ - protected DOMDocument $dom; + private DOMDocument $dom; /** * This holds the root node of the OAI-PMH XML response. */ - protected DOMElement $rootNode; - - /** - * This holds the current server request. - */ - protected ServerRequestInterface $serverRequest; + private DOMElement $rootNode; /** * Add XSL processing instructions to XML response document. @@ -61,24 +56,24 @@ class Document { $uri = $this->serverRequest->getUri(); $basePath = $uri->getPath(); - if (str_ends_with($basePath, 'index.php')) { - $basePath = pathinfo($basePath, PATHINFO_DIRNAME); + if (str_ends_with(haystack: $basePath, needle: 'index.php')) { + $basePath = pathinfo(path: $basePath, flags: PATHINFO_DIRNAME); } $stylesheet = Uri::composeComponents( - $uri->getScheme(), - $uri->getAuthority(), - rtrim($basePath, '/') . '/resources/stylesheet.xsl', - null, - null + scheme: $uri->getScheme(), + authority: $uri->getAuthority(), + path: rtrim(string: $basePath, characters: '/') . '/resources/stylesheet.xsl', + query: null, + fragment: null ); $xslt = $this->dom->createProcessingInstruction( - 'xml-stylesheet', - sprintf( - 'type="text/xsl" href="%s"', - $stylesheet + target: 'xml-stylesheet', + data: sprintf( + format: 'type="text/xsl" href="%s"', + values: $stylesheet ) ); - $this->dom->appendChild($xslt); + $this->dom->appendChild(node: $xslt); } /** @@ -90,20 +85,27 @@ class Document { $uri = $this->serverRequest->getUri(); $baseUrl = Uri::composeComponents( - $uri->getScheme(), - $uri->getAuthority(), - $uri->getPath(), - null, - null + scheme: $uri->getScheme(), + authority: $uri->getAuthority(), + path: $uri->getPath(), + query: null, + fragment: null + ); + $request = $this->createElement( + localName: 'request', + value: $baseUrl, + appendToRoot: true ); - $request = $this->dom->createElement('request', $baseUrl); - $this->rootNode->appendChild($request); /** @var array */ $params = $this->serverRequest->getAttributes(); foreach ($params as $param => $value) { $request->setAttribute( - $param, - htmlspecialchars($value, ENT_XML1 | ENT_COMPAT, 'UTF-8') + qualifiedName: $param, + value: htmlspecialchars( + string: $value, + flags: ENT_XML1 | ENT_COMPAT, + encoding: 'UTF-8' + ) ); } } @@ -115,8 +117,11 @@ class Document */ protected function appendResponseDate(): void { - $responseDate = $this->dom->createElement('responseDate', gmdate('Y-m-d\TH:i:s\Z')); - $this->rootNode->appendChild($responseDate); + $this->createElement( + localName: 'responseDate', + value: gmdate(format: 'Y-m-d\TH:i:s\Z'), + appendToRoot: true + ); } /** @@ -126,20 +131,20 @@ class Document */ protected function appendRootElement(): void { - $this->rootNode = $this->dom->createElement('OAI-PMH'); + $this->rootNode = $this->dom->createElement(localName: 'OAI-PMH'); $this->rootNode->setAttribute( - 'xmlns', - 'http://www.openarchives.org/OAI/2.0/' + qualifiedName: 'xmlns', + value: 'http://www.openarchives.org/OAI/2.0/' ); $this->rootNode->setAttribute( - 'xmlns:xsi', - 'http://www.w3.org/2001/XMLSchema-instance' + qualifiedName: 'xmlns:xsi', + value: 'http://www.w3.org/2001/XMLSchema-instance' ); $this->rootNode->setAttribute( - 'xsi:schemaLocation', - 'http://www.openarchives.org/OAI/2.0/ https://www.openarchives.org/OAI/2.0/OAI-PMH.xsd' + qualifiedName: 'xsi:schemaLocation', + value: 'http://www.openarchives.org/OAI/2.0/ https://www.openarchives.org/OAI/2.0/OAI-PMH.xsd' ); - $this->dom->appendChild($this->rootNode); + $this->dom->appendChild(node: $this->rootNode); } /** @@ -149,7 +154,7 @@ class Document */ protected function createDocument(): void { - $this->dom = new DOMDocument('1.0', 'UTF-8'); + $this->dom = new DOMDocument(version: '1.0', encoding: 'UTF-8'); $this->dom->preserveWhiteSpace = false; $this->addProcessingInstructions(); } @@ -166,11 +171,15 @@ class Document public function createElement(string $localName, string $value = '', bool $appendToRoot = false): DOMElement { $node = $this->dom->createElement( - $localName, - htmlspecialchars($value, ENT_XML1, 'UTF-8') + localName: $localName, + value: htmlspecialchars( + string: $value, + flags: ENT_XML1, + encoding: 'UTF-8' + ) ); if ($appendToRoot) { - $this->rootNode->appendChild($node); + $this->rootNode->appendChild(node: $node); } return $node; } @@ -182,21 +191,21 @@ class Document * * @return DOMNode The imported XML node * - * @throws DOMException + * @throws DOMException if the data cannot be imported */ public function importData(string $data): DOMNode { - $document = new DOMDocument('1.0', 'UTF-8'); + $document = new DOMDocument(version: '1.0', encoding: 'UTF-8'); $document->preserveWhiteSpace = false; - if ($document->loadXML($data) === true) { + if ($document->loadXML(source: $data) === true) { /** @var DOMElement */ $rootNode = $document->documentElement; - $node = $this->dom->importNode($rootNode, true); + $node = $this->dom->importNode(node: $rootNode, deep: true); return $node; } else { throw new DOMException( - 'Could not import the XML data. Most likely it is not well-formed.', - 500 + message: 'Could not import the XML data. Most likely it is not well-formed.', + code: 500 ); } } @@ -206,9 +215,8 @@ class Document * * @param ServerRequestInterface $serverRequest The PSR-7 HTTP Server Request */ - public function __construct(ServerRequestInterface $serverRequest) + public function __construct(private ServerRequestInterface $serverRequest) { - $this->serverRequest = $serverRequest; $this->createDocument(); $this->appendRootElement(); $this->appendResponseDate(); diff --git a/src/Result.php b/src/ResultSet.php similarity index 63% rename from src/Result.php rename to src/ResultSet.php index f92db3f..fce32ae 100644 --- a/src/Result.php +++ b/src/ResultSet.php @@ -22,13 +22,7 @@ declare(strict_types=1); namespace OCC\OaiPmh2; -use Countable; -use Iterator; -use OCC\Basics\InterfaceTraits\Countable as CountableTrait; -use OCC\Basics\InterfaceTraits\Iterator as IteratorTrait; -use OCC\OaiPmh2\Entity\Format; -use OCC\OaiPmh2\Entity\Record; -use OCC\OaiPmh2\Entity\Set; +use Doctrine\Common\Collections\ArrayCollection; use OCC\OaiPmh2\Entity\Token; /** @@ -37,35 +31,15 @@ use OCC\OaiPmh2\Entity\Token; * @author Sebastian Meyer * @package OAIPMH2 * - * @template QueryResult of array - * @implements Iterator + * @template TEntity of Entity + * @extends ArrayCollection */ -class Result implements Countable, Iterator +final class ResultSet extends ArrayCollection { - use CountableTrait; - use IteratorTrait; - - /** - * This holds the Doctrine result set. - * - * @var QueryResult - */ - private array $data; - /** * This holds the optional resumption token. */ - protected ?Token $resumptionToken = null; - - /** - * Get the query result. - * - * @return QueryResult The result set - */ - public function getQueryResult(): array - { - return $this->data; - } + private ?Token $resumptionToken; /** * Get the resumption token. @@ -92,10 +66,12 @@ class Result implements Countable, Iterator /** * Create new result set. * - * @param QueryResult $queryResult The Doctrine result set + * @param array $elements Array of entities + * @param Token $token Optional resumption token */ - public function __construct(array $queryResult) + public function __construct(array $elements = [], Token $token = null) { - $this->data = $queryResult; + parent::__construct(elements: $elements); + $this->resumptionToken = $token; } } diff --git a/src/Validator/ConfigurationValidator.php b/src/Validator/ConfigurationValidator.php new file mode 100644 index 0000000..17a622c --- /dev/null +++ b/src/Validator/ConfigurationValidator.php @@ -0,0 +1,114 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +declare(strict_types=1); + +namespace OCC\OaiPmh2\Validator; + +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\ConstraintViolationListInterface; +use Symfony\Component\Validator\Validation; + +/** + * Validator for configuration settings. + * + * @author Sebastian Meyer + * @package OAIPMH2 + */ +class ConfigurationValidator +{ + /** + * Get constraints for configuration array. + * + * @return array The collection of constraints + */ + protected static function getValidationConstraints(): array + { + return [ + new Assert\Collection( + fields: [ + 'repositoryName' => [ + new Assert\Type(type: 'string'), + new Assert\NotBlank() + ], + 'adminEmail' => [ + new Assert\Type(type: 'string'), + new Assert\Email(options: ['mode' => 'html5']), + new Assert\NotBlank() + ], + 'database' => [ + new Assert\Type(type: 'string'), + new Assert\NotBlank() + ], + 'metadataPrefix' => [ + new Assert\Type(type: 'array'), + new Assert\All( + constraints: [ + new Assert\Collection( + fields: [ + 'schema' => [ + new Assert\Type(type: 'string'), + new Assert\Url(), + new Assert\NotBlank() + ], + 'namespace' => [ + new Assert\Type(type: 'string'), + new Assert\Url(), + new Assert\NotBlank() + ] + ] + ) + ] + ) + ], + 'deletedRecords' => [ + new Assert\Type(type: 'string'), + new Assert\Choice(options: ['no', 'persistent', 'transient']), + new Assert\NotBlank() + ], + 'maxRecords' => [ + new Assert\Type(type: 'int'), + new Assert\Range(options: ['min' => 1, 'max' => 100]) + ], + 'tokenValid' => [ + new Assert\Type(type: 'int'), + new Assert\Range(options: ['min' => 300, 'max' => 86400]) + ] + ] + ) + ]; + } + + /** + * Validate the given configuration array. + * + * @param array $config The configuration array to validate + * + * @return ConstraintViolationListInterface The list of violations + */ + public static function validate(array $config): ConstraintViolationListInterface + { + return Validation::createValidator()->validate( + value: $config, + constraints: self::getValidationConstraints() + ); + } +} diff --git a/src/Validator/RegExValidator.php b/src/Validator/RegExValidator.php new file mode 100644 index 0000000..b1a059c --- /dev/null +++ b/src/Validator/RegExValidator.php @@ -0,0 +1,72 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +declare(strict_types=1); + +namespace OCC\OaiPmh2\Validator; + +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\ConstraintViolationListInterface; +use Symfony\Component\Validator\Validation; + +/** + * Validator for Regular Expressions. + * + * @author Sebastian Meyer + * @package OAIPMH2 + */ +class RegExValidator +{ + /** + * Get constraints for regular expression. + * + * @param string $regEx The regular expression for validation + * + * @return array The array of constraints + */ + protected static function getValidationConstraints(string $regEx): array + { + return [ + new Assert\Regex( + pattern: [ + 'pattern' => $regEx, + 'message' => 'This value does not match the regular expression "{{ pattern }}".' + ] + ) + ]; + } + + /** + * Check if a string matches a given regular expression. + * + * @param string $string The string + * @param string $regEx The regular expression + * + * @return ConstraintViolationListInterface The list of violations + */ + public static function validate(string $string, string $regEx): ConstraintViolationListInterface + { + return Validation::createValidator()->validate( + value: $string, + constraints: self::getValidationConstraints(regEx: $regEx) + ); + } +} diff --git a/src/Validator/UrlValidator.php b/src/Validator/UrlValidator.php new file mode 100644 index 0000000..e345570 --- /dev/null +++ b/src/Validator/UrlValidator.php @@ -0,0 +1,65 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +declare(strict_types=1); + +namespace OCC\OaiPmh2\Validator; + +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\ConstraintViolationListInterface; +use Symfony\Component\Validator\Validation; + +/** + * Validator for URLs. + * + * @author Sebastian Meyer + * @package OAIPMH2 + */ +class UrlValidator +{ + /** + * Get constraints for URLs. + * + * @return array The array of constraints + */ + protected static function getValidationConstraints(): array + { + return [ + new Assert\Url(), + new Assert\NotBlank() + ]; + } + + /** + * Check if the given string is a valid URL. + * + * @param string $url The URL + * + * @return ConstraintViolationListInterface The list of violations + */ + public static function validate(string $url): ConstraintViolationListInterface + { + return Validation::createValidator()->validate( + value: $url, + constraints: self::getValidationConstraints() + ); + } +} diff --git a/src/Validator/XmlValidator.php b/src/Validator/XmlValidator.php new file mode 100644 index 0000000..805f1b9 --- /dev/null +++ b/src/Validator/XmlValidator.php @@ -0,0 +1,79 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +declare(strict_types=1); + +namespace OCC\OaiPmh2\Validator; + +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationListInterface; +use Symfony\Component\Validator\Validation; + +/** + * Validator for XML. + * + * @author Sebastian Meyer + * @package OAIPMH2 + */ +class XmlValidator +{ + /** + * Get constraints for XML. + * + * @return array The array of constraints + */ + protected static function getValidationConstraints(): array + { + return [ + new Assert\Type(type: 'string'), + new Assert\NotBlank() + ]; + } + + /** + * Check if the given string is valid XML. + * + * @param string $xml The XML string + * + * @return ConstraintViolationListInterface The list of violations + */ + public static function validate(string $xml): ConstraintViolationListInterface + { + $violations = Validation::createValidator()->validate( + value: $xml, + constraints: self::getValidationConstraints() + ); + if (simplexml_load_string(data: $xml) === false) { + $violations->add( + violation: new ConstraintViolation( + message: 'Value could not be parsed as XML.', + messageTemplate: 'Value could not be parsed as XML.', + parameters: [], + root: $xml, + propertyPath: null, + invalidValue: $xml + ) + ); + } + return $violations; + } +}