Refactored and cleaned code

This commit is contained in:
Sebastian Meyer 2024-09-30 19:37:29 +02:00
parent d16efe47ed
commit 97fee92f63
46 changed files with 2619 additions and 1789 deletions

View File

@ -7,20 +7,35 @@
xsi:noNamespaceSchemaLocation=" xsi:noNamespaceSchemaLocation="
http://pmd.sf.net/ruleset_xml_schema.xsd"> http://pmd.sf.net/ruleset_xml_schema.xsd">
<description> <description>
Open Culture Consulting follows PHP Mess Detector standards. Open Culture Consulting follows PHP Mess Detector standards with few exceptions.
</description> </description>
<rule ref="rulesets/cleancode.xml"> <rule ref="rulesets/cleancode.xml">
<!--
We sometimes want to use else expressions for better readability.
-->
<exclude name="ElseExpression" /> <exclude name="ElseExpression" />
<!-- We need to statically access third-party helpers from Symfony. -->
<exclude name="StaticAccess" /> <exclude name="StaticAccess" />
<exclude name="BooleanArgumentFlag" />
</rule> </rule>
<rule ref="rulesets/codesize.xml" /> <rule ref="rulesets/codesize.xml" />
<rule ref="rulesets/controversial.xml" /> <rule ref="rulesets/controversial.xml" />
<rule ref="rulesets/design.xml" /> <rule ref="rulesets/design.xml" />
<rule ref="rulesets/naming.xml" /> <rule ref="rulesets/naming.xml">
<exclude name="ShortVariable" />
</rule>
<rule ref="rulesets/naming.xml/ShortVariable">
<properties>
<!--
We want to allow shorter variable names as long as they are self-explanatory.
-->
<property name="minimum" value="2" />
</properties>
</rule>
<rule ref="rulesets/unusedcode.xml"> <rule ref="rulesets/unusedcode.xml">
<!-- We have to declare unused parameters to satisfy interface requirements. --> <!--
We have to declare unused parameters to satisfy interface requirements.
-->
<exclude name="UnusedFormalParameter" /> <exclude name="UnusedFormalParameter" />
</rule> </rule>
</ruleset> </ruleset>

View File

@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at reported to the community leaders responsible for enforcement at
office@opencultureconsulting.com. <office@opencultureconsulting.com>.
All complaints will be reviewed and investigated promptly and fairly. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the All community leaders are obligated to respect the privacy and security of the

View File

@ -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]: <https://opensource.guide/how-to-contribute/>
If you have any further questions just [open a new issue][issuetracker] and
I'll be happy to assist!
[issuetracker]: <https://github.com/opencultureconsulting/oai-pmh2/issues/new>

View File

@ -49,7 +49,7 @@ $commands = [
try { try {
ConsoleRunner::run( ConsoleRunner::run(
new SingleManagerProvider( new SingleManagerProvider(
Database::getInstance()->getEntityManager() EntityManager::getInstance()
), ),
$commands $commands
); );

View File

@ -31,10 +31,10 @@
"ext-dom": "*", "ext-dom": "*",
"ext-libxml": "*", "ext-libxml": "*",
"ext-sqlite3": "*", "ext-sqlite3": "*",
"doctrine/dbal": "^3.8", "doctrine/dbal": "^4.1",
"doctrine/orm": "^3.2", "doctrine/orm": "^3.2",
"opencultureconsulting/basics": "^1.1", "opencultureconsulting/basics": "^2.1",
"opencultureconsulting/psr15": "^1.0", "opencultureconsulting/psr15": "^1.2",
"symfony/cache": "^6.4", "symfony/cache": "^6.4",
"symfony/console": "^6.4", "symfony/console": "^6.4",
"symfony/filesystem": "^6.4", "symfony/filesystem": "^6.4",
@ -44,11 +44,13 @@
"require-dev": { "require-dev": {
"phpdocumentor/shim": "^3.5", "phpdocumentor/shim": "^3.5",
"phpmd/phpmd": "^2.15", "phpmd/phpmd": "^2.15",
"phpstan/phpstan": "^1.11", "phpstan/phpstan": "^1.12",
"phpstan/phpstan-doctrine": "^1.5",
"phpstan/phpstan-strict-rules": "^1.6", "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", "squizlabs/php_codesniffer": "^3.10",
"vimeo/psalm": "^5.25" "vimeo/psalm": "^5.26"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@ -74,7 +76,7 @@
], ],
"doctrine:clear-cache": [ "doctrine:clear-cache": [
"@php bin/cli orm:clear-cache:metadata --flush", "@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" "@php bin/cli orm:clear-cache:result --flush"
], ],
"doctrine:initialize-database": [ "doctrine:initialize-database": [
@ -95,7 +97,7 @@
], ],
"phpmd:check": [ "phpmd:check": [
"@php -r \"if (!file_exists('./.phpmd.xml')) { copy('./.phpmd.dist.xml', './.phpmd.xml'); }\"", "@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": [ "phpstan:check": [
"@php vendor/bin/phpstan" "@php vendor/bin/phpstan"

738
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,11 @@
<?xml version="1.0"?> <?xml version="1.0"?>
<ruleset name="OCC Standard Ruleset"> <ruleset name="OCC Standard Ruleset">
<description>Open Culture Consulting strictly follows PSR standards.</description> <description>Open Culture Consulting strictly follows PSR standards.</description>
<file>./bin</file>
<file>./public</file>
<file>./src</file> <file>./src</file>
<arg name="extensions" value="php"/> <arg name="extensions" value="php"/>
<rule ref="PSR12"> <rule ref="PSR12">
<exclude name="Generic.Files.LineLength"/> <exclude name="Generic.Files.LineLength"/>
<exclude name="PSR2.Classes.PropertyDeclaration.Underscore"/>
<exclude name="PSR2.Methods.MethodDeclaration.Underscore"/>
</rule> </rule>
</ruleset> </ruleset>

View File

@ -5,11 +5,14 @@
includes: includes:
- vendor/phpstan/phpstan-strict-rules/rules.neon - vendor/phpstan/phpstan-strict-rules/rules.neon
- vendor/phpstan/phpstan-doctrine/extension.neon
- vendor/phpstan/phpstan-symfony/extension.neon
parameters: parameters:
level: 9 level: 9
strictRules: treatPhpDocTypesAsCertain: false
noVariableVariables: false ignoreErrors:
- identifier: ternary.shortNotAllowed
paths: paths:
- bin - bin
- public - public

View File

@ -8,23 +8,40 @@
findUnusedBaselineEntry="true" findUnusedBaselineEntry="true"
findUnusedCode="true" findUnusedCode="true"
findUnusedVariablesAndParams="true" findUnusedVariablesAndParams="true"
reportMixedIssues="false"
> >
<issueHandlers> <issueHandlers>
<!-- <!--
Psalm doesn't recognize $columns['idColumn'] and $columns['contentColumn'] always being set in execute(). Psalm doesn't recognize some variables always being set because of prior validation.
--> -->
<PossiblyNullArrayOffset> <PossiblyNullReference>
<errorLevel type="suppress"> <errorLevel type="suppress">
<file name="src/Console/CsvImportCommand.php"/> <file name="src/Console/UpdateFormatsCommand.php"/>
</errorLevel> </errorLevel>
</PossiblyNullArrayOffset> </PossiblyNullReference>
<!--
DBAL entities require getter/setter methods even if they are never called directly.
-->
<PossiblyUnusedMethod errorLevel="suppress"/> <PossiblyUnusedMethod errorLevel="suppress"/>
<PossiblyUnusedReturnValue errorLevel="suppress"/> <PossiblyUnusedReturnValue errorLevel="suppress"/>
<!--
Some properties are not set in the constructor and hence checked for initialization.
-->
<PropertyNotSetInConstructor errorLevel="suppress"/> <PropertyNotSetInConstructor errorLevel="suppress"/>
<RedundantCastGivenDocblockType errorLevel="suppress"/>
<RedundantConditionGivenDocblockType errorLevel="suppress"/>
<RedundantFunctionCallGivenDocblockType errorLevel="suppress"/>
<RedundantPropertyInitializationCheck errorLevel="suppress"/> <RedundantPropertyInitializationCheck errorLevel="suppress"/>
<!--
We deliberately want to evaluate empty strings as FALSE in those files.
-->
<RiskyTruthyFalsyComparison>
<errorLevel type="suppress">
<file name="src/Console/AddRecordCommand.php"/>
<file name="src/Console/AddSetCommand.php"/>
</errorLevel>
</RiskyTruthyFalsyComparison>
<!--
Those classes are dynamically used depending on the given OAI verb.
@see src/Middleware/Dispatcher.php:95
-->
<UnusedClass> <UnusedClass>
<errorLevel type="suppress"> <errorLevel type="suppress">
<referencedClass name="OCC\OaiPmh2\Middleware\GetRecord"/> <referencedClass name="OCC\OaiPmh2\Middleware\GetRecord"/>

View File

@ -31,19 +31,19 @@ use OCC\PSR15\QueueRequestHandler;
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com> * @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package OAIPMH2 * @package OAIPMH2
*/ */
class App final class App
{ {
/** /**
* The PSR-15 Server Request Handler. * The PSR-15 Server Request Handler.
*/ */
protected QueueRequestHandler $requestHandler; private QueueRequestHandler $requestHandler;
/** /**
* Instantiate application. * Instantiate application.
*/ */
public function __construct() 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 public function run(): void
{ {
$this->requestHandler->handle(); $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(); $this->requestHandler->respond();
} }
} }

View File

@ -23,11 +23,10 @@ declare(strict_types=1);
namespace OCC\OaiPmh2; namespace OCC\OaiPmh2;
use OCC\Basics\Traits\Singleton; use OCC\Basics\Traits\Singleton;
use OCC\OaiPmh2\Validator\ConfigurationValidator;
use Symfony\Component\Filesystem\Exception\FileNotFoundException; use Symfony\Component\Filesystem\Exception\FileNotFoundException;
use Symfony\Component\Filesystem\Path; use Symfony\Component\Filesystem\Path;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Exception\ValidationFailedException; use Symfony\Component\Validator\Exception\ValidationFailedException;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
/** /**
@ -36,18 +35,18 @@ use Symfony\Component\Yaml\Yaml;
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com> * @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package OAIPMH2 * @package OAIPMH2
* *
* @property-read string $repositoryName * @property-read string $repositoryName Common name of this repository
* @property-read string $adminEmail * @property-read string $adminEmail Repository contact's e-mail address
* @property-read string $database * @property-read string $database Database's data source name (DSN)
* @property-read array $metadataPrefix * @property-read array $metadataPrefix Array of served metadata prefixes
* @property-read string $deletedRecords * @property-read string $deletedRecords Repository's deleted records policy
* @property-read int $maxRecords * @property-read int $maxRecords Maximum number of records served per request
* @property-read int $tokenValid * @property-read int $tokenValid Number of seconds resumption tokens are valid
* *
* @template TKey of string * @template TKey of string
* @template TValue of array|int|string * @template TValue of array|int|string
*/ */
class Configuration final class Configuration
{ {
use Singleton; use Singleton;
@ -65,98 +64,6 @@ class Configuration
*/ */
protected readonly array $settings; 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<TKey, TValue> 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<TKey, TValue> */
$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. * Load and validate configuration settings from YAML file.
* *
@ -165,11 +72,24 @@ class Configuration
*/ */
private function __construct() private function __construct()
{ {
try { $configPath = Path::canonicalize(path: self::CONFIG_FILE);
$this->settings = $this->loadConfigFile(); if (!is_readable(filename: $configPath)) {
} catch (FileNotFoundException | ValidationFailedException $exception) { throw new FileNotFoundException(
throw $exception; message: 'Configuration file not found or not readable.',
code: 500,
path: $configPath
);
} }
/** @var array<TKey, TValue> */
$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 * @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 public function __get(string $name): mixed
{ {

View File

@ -34,9 +34,53 @@ use Symfony\Component\Console\Output\OutputInterface;
* *
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com> * @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package OAIPMH2 * @package OAIPMH2
*
* @psalm-type CliArguments = array{
* identifier: string,
* format: string,
* file: string,
* sets?: list<string>,
* setSpec: string,
* setName: string,
* idColumn: string,
* contentColumn: string,
* dateColumn: string,
* setColumn: string,
* noValidation: bool,
* force: bool
* }
*/ */
abstract class Console extends Command 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. * Clears the result cache.
* *
@ -47,11 +91,13 @@ abstract class Console extends Command
/** @var Application */ /** @var Application */
$app = $this->getApplication(); $app = $this->getApplication();
$app->doRun( $app->doRun(
new ArrayInput([ input: new ArrayInput(
parameters: [
'command' => 'orm:clear-cache:result', 'command' => 'orm:clear-cache:result',
'--flush' => true '--flush' => true
]), ]
new NullOutput() ),
output: new NullOutput()
); );
} }
@ -62,7 +108,8 @@ abstract class Console extends Command
*/ */
protected function getPhpMemoryLimit(): int protected function getPhpMemoryLimit(): int
{ {
$ini = trim(ini_get('memory_limit')); if (!isset($this->memoryLimit)) {
$ini = trim(string: ini_get(option: 'memory_limit'));
$limit = (int) $ini; $limit = (int) $ini;
if ($limit < 0) { if ($limit < 0) {
return -1; return -1;
@ -78,7 +125,9 @@ abstract class Console extends Command
case 'k': case 'k':
$limit *= 1024; $limit *= 1024;
} }
return $limit; $this->memoryLimit = $limit;
}
return $this->memoryLimit;
} }
/** /**
@ -91,32 +140,68 @@ abstract class Console extends Command
*/ */
protected function validateInput(InputInterface $input, OutputInterface $output): bool protected function validateInput(InputInterface $input, OutputInterface $output): bool
{ {
/** @var array<string, string> */ /** @var CliArguments */
$arguments = $input->getArguments(); $mergedArguments = array_merge($input->getArguments(), $input->getOptions());
$this->arguments = $mergedArguments;
$formats = Database::getInstance()->getMetadataFormats()->getQueryResult(); if (array_key_exists('format', $this->arguments)) {
if (!array_key_exists($arguments['format'], $formats)) { $formats = $this->em->getMetadataFormats();
$output->writeln([ if (!$formats->containsKey(key: $this->arguments['format'])) {
$output->writeln(
messages: [
'', '',
sprintf( sprintf(
' [ERROR] Metadata format "%s" is not supported. ', format: ' [ERROR] Metadata format "%s" is not supported. ',
$arguments['format'] values: $this->arguments['format']
), ),
'' ''
]); ]
);
return false; return false;
} }
if (!is_readable($arguments['file'])) { }
$output->writeln([ if (array_key_exists('file', $this->arguments) && !is_readable(filename: $this->arguments['file'])) {
$output->writeln(
messages: [
'', '',
sprintf( sprintf(
' [ERROR] File "%s" not found or not readable. ', format: ' [ERROR] File "%s" not found or not readable. ',
$arguments['file'] values: $this->arguments['file']
), ),
'' ''
]); ]
);
return false; 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; 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);
}
} }

View File

@ -23,7 +23,6 @@ declare(strict_types=1);
namespace OCC\OaiPmh2\Console; namespace OCC\OaiPmh2\Console;
use OCC\OaiPmh2\Console; use OCC\OaiPmh2\Console;
use OCC\OaiPmh2\Database;
use OCC\OaiPmh2\Entity\Format; use OCC\OaiPmh2\Entity\Format;
use OCC\OaiPmh2\Entity\Record; use OCC\OaiPmh2\Entity\Record;
use OCC\OaiPmh2\Entity\Set; use OCC\OaiPmh2\Entity\Set;
@ -70,7 +69,7 @@ class AddRecordCommand extends Console
$this->addArgument( $this->addArgument(
'sets', 'sets',
InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 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(); parent::configure();
} }
@ -85,36 +84,31 @@ class AddRecordCommand extends Console
*/ */
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
if (!$this->validateInput($input, $output)) { if (!$this->validateInput(input: $input, output: $output)) {
return Command::INVALID; return Command::INVALID;
} }
/** @var string */
$identifier = $input->getArgument('identifier');
/** @var Format */ /** @var Format */
$format = Database::getInstance() $format = $this->em->getMetadataFormat(prefix: $this->arguments['format']);
->getEntityManager() $content = file_get_contents(filename: $this->arguments['file']) ?: '';
->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);
$record = new Record($identifier, $format); $record = new Record(
identifier: $this->arguments['identifier'],
format: $format
);
if (trim($content) !== '') { if (trim($content) !== '') {
$record->setContent($content); $record->setContent(data: $content);
} }
foreach ($sets as $set) { if (array_key_exists('sets', $this->arguments)) {
foreach ($this->arguments['sets'] as $set) {
/** @var Set */ /** @var Set */
$setSpec = Database::getInstance() $setSpec = $this->em->getSet(spec: $set);
->getEntityManager() $record->addSet(set: $setSpec);
->getReference(Set::class, $set); }
$record->addSet($setSpec);
} }
Database::getInstance()->addOrUpdateRecord($record); $this->em->addOrUpdate(entity: $record);
Database::getInstance()->pruneOrphanSets(); $this->em->pruneOrphanedSets();
$this->clearResultCache(); $this->clearResultCache();
@ -122,7 +116,7 @@ class AddRecordCommand extends Console
'', '',
sprintf( sprintf(
' [OK] Record "%s" with metadata prefix "%s" added or updated successfully! ', ' [OK] Record "%s" with metadata prefix "%s" added or updated successfully! ',
$identifier, $this->arguments['identifier'],
$format->getPrefix() $format->getPrefix()
), ),
'' ''

View File

@ -23,7 +23,6 @@ declare(strict_types=1);
namespace OCC\OaiPmh2\Console; namespace OCC\OaiPmh2\Console;
use OCC\OaiPmh2\Console; use OCC\OaiPmh2\Console;
use OCC\OaiPmh2\Database;
use OCC\OaiPmh2\Entity\Set; use OCC\OaiPmh2\Entity\Set;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
@ -53,10 +52,10 @@ class AddSetCommand extends Console
$this->addArgument( $this->addArgument(
'setSpec', 'setSpec',
InputArgument::REQUIRED, InputArgument::REQUIRED,
'The set (spec) to update.', 'The set (spec) to add or update.',
null, null,
function (): array { function (): array {
return array_keys(Database::getInstance()->getAllSets()->getQueryResult()); return $this->em->getSets()->getKeys();
} }
); );
$this->addArgument( $this->addArgument(
@ -67,7 +66,7 @@ class AddSetCommand extends Console
$this->addArgument( $this->addArgument(
'file', 'file',
InputArgument::OPTIONAL, InputArgument::OPTIONAL,
'The optional file containing the set description XML.' 'Optional: The file containing the set description XML.'
); );
parent::configure(); parent::configure();
} }
@ -82,32 +81,20 @@ class AddSetCommand extends Console
*/ */
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
/** @var array<string, string> */ if (!$this->validateInput(input: $input, output: $output)) {
$arguments = $input->getArguments();
$description = null;
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; 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( $set = new Set(
$arguments['setSpec'], spec: $this->arguments['setSpec'],
$arguments['setName'], name: $this->arguments['setName'],
$description description: $description ?? null
); );
Database::getInstance()->addOrUpdateSet($set); $this->em->addOrUpdate(entity: $set);
return Command::SUCCESS; return Command::SUCCESS;
} }

View File

@ -23,9 +23,7 @@ declare(strict_types=1);
namespace OCC\OaiPmh2\Console; namespace OCC\OaiPmh2\Console;
use DateTime; use DateTime;
use OCC\OaiPmh2\Configuration;
use OCC\OaiPmh2\Console; use OCC\OaiPmh2\Console;
use OCC\OaiPmh2\Database;
use OCC\OaiPmh2\Entity\Format; use OCC\OaiPmh2\Entity\Format;
use OCC\OaiPmh2\Entity\Record; use OCC\OaiPmh2\Entity\Record;
use OCC\OaiPmh2\Entity\Set; use OCC\OaiPmh2\Entity\Set;
@ -42,6 +40,13 @@ use Symfony\Component\Console\Output\OutputInterface;
* *
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com> * @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package OAIPMH2 * @package OAIPMH2
*
* @psalm-type ColumnMapping = array{
* idColumn: int,
* contentColumn: int,
* dateColumn: ?int,
* setColumn: ?int
* }
*/ */
#[AsCommand( #[AsCommand(
name: 'oai:records:import:csv', name: 'oai:records:import:csv',
@ -62,7 +67,7 @@ class CsvImportCommand extends Console
'The format (metadata prefix) of the records.', 'The format (metadata prefix) of the records.',
null, null,
function (): array { function (): array {
return array_keys(Database::getInstance()->getMetadataFormats()->getQueryResult()); return $this->em->getMetadataFormats()->getKeys();
} }
); );
$this->addArgument( $this->addArgument(
@ -88,21 +93,21 @@ class CsvImportCommand extends Console
'dateColumn', 'dateColumn',
'd', 'd',
InputOption::VALUE_OPTIONAL, 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' 'lastChanged'
); );
$this->addOption( $this->addOption(
'setColumn', 'setColumn',
's', 's',
InputOption::VALUE_OPTIONAL, 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' 'sets'
); );
$this->addOption( $this->addOption(
'noValidation', 'noValidation',
null, null,
InputOption::VALUE_NONE, 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(); parent::configure();
} }
@ -117,69 +122,60 @@ class CsvImportCommand extends Console
*/ */
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
if (!$this->validateInput($input, $output)) { if (!$this->validateInput(input: $input, output: $output)) {
return Command::INVALID; return Command::INVALID;
} }
$phpMemoryLimit = $this->getPhpMemoryLimit();
/** @var array<string, string> */
$arguments = $input->getArguments();
/** @var bool */
$noValidation = $input->getOption('noValidation');
/** @var resource */ /** @var resource */
$file = fopen($arguments['file'], 'r'); $file = fopen(filename: $this->arguments['file'], mode: 'r');
$columns = $this->getColumnNames($input, $output, $file); $columnMapping = $this->getColumnNames(input: $input, output: $output, file: $file);
if (count($columns) === 0) {
return Command::INVALID; if (!isset($columnMapping)) {
return Command::FAILURE;
} }
$count = 0; $count = 0;
$progressIndicator = new ProgressIndicator($output, null, 100, ['⠏', '⠛', '⠹', '⢸', '⣰', '⣤', '⣆', '⡇']); $progressIndicator = new ProgressIndicator($output, null, 100, ['⠏', '⠛', '⠹', '⢸', '⣰', '⣤', '⣆', '⡇']);
$progressIndicator->start('Importing...'); $progressIndicator->start('Importing...');
while ($row = fgetcsv($file)) { while ($row = fgetcsv(stream: $file)) {
/** @var Format */ /** @var Format */
$format = Database::getInstance() $format = $this->em->getMetadataFormat(prefix: $this->arguments['format']);
->getEntityManager() $record = new Record(
->getReference(Format::class, $arguments['format']); identifier: $row[$columnMapping['idColumn']],
$record = new Record($row[$columns['idColumn']], $format); format: $format
if (strlen(trim($row[$columns['contentColumn']])) > 0) { );
$record->setContent($row[$columns['contentColumn']], !$noValidation); if (strlen(trim($row[$columnMapping['contentColumn']])) > 0) {
$record->setContent(
data: $row[$columnMapping['contentColumn']],
validate: !$this->arguments['noValidation']
);
} }
if (isset($columns['dateColumn'])) { if (isset($columnMapping['dateColumn'])) {
$record->setLastChanged(new DateTime($row[$columns['dateColumn']])); $record->setLastChanged(dateTime: new DateTime($row[$columnMapping['dateColumn']]));
} }
if (isset($columns['setColumn'])) { if (isset($columnMapping['setColumn'])) {
$sets = $row[$columns['setColumn']]; $sets = $row[$columnMapping['setColumn']];
foreach (explode(',', $sets) as $set) { foreach (explode(',', $sets) as $set) {
/** @var Set */ /** @var Set */
$setSpec = Database::getInstance() $setSpec = $this->em->getSet(spec: trim($set));
->getEntityManager() $record->addSet(set: $setSpec);
->getReference(Set::class, trim($set));
$record->addSet($setSpec);
} }
} }
Database::getInstance()->addOrUpdateRecord($record, true); $this->em->addOrUpdate(entity: $record, bulkMode: true);
++$count; ++$count;
$progressIndicator->advance(); $progressIndicator->advance();
$progressIndicator->setMessage('Importing... ' . (string) $count . ' records processed.'); $progressIndicator->setMessage('Importing... ' . (string) $count . ' records processed.');
$this->checkMemoryUsage();
// 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->em->flush();
Database::getInstance()->flush(true); $this->em->pruneOrphanedSets();
Database::getInstance()->pruneOrphanSets();
$progressIndicator->finish('All done!'); $progressIndicator->finish('All done!');
fclose($file); fclose(stream: $file);
$this->clearResultCache(); $this->clearResultCache();
@ -188,7 +184,7 @@ class CsvImportCommand extends Console
sprintf( sprintf(
' [OK] %d records with metadata prefix "%s" were imported successfully! ', ' [OK] %d records with metadata prefix "%s" were imported successfully! ',
$count, $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 InputInterface $input The input
* @param OutputInterface $output The output interface * @param OutputInterface $output The output
* @param resource $file The handle for the CSV file * @param resource $file The handle for the CSV file
* *
* @return array<string, int|string|null> 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<string, string> */ /** @var array{idColumn: string, contentColumn: string, dateColumn: string, setColumn: string} */
$options = $input->getOptions(); $columns = [
'idColumn' => $input->getOption('idColumn'),
$columns = []; 'contentColumn' => $input->getOption('contentColumn'),
'dateColumn' => $input->getOption('dateColumn'),
'setColumn' => $input->getOption('setColumn')
];
$headers = fgetcsv($file); $headers = fgetcsv($file);
if (!is_array($headers)) { if (!is_array($headers) || !isset($headers[0])) {
$output->writeln([ $output->writeln([
'', '',
sprintf( sprintf(
' [ERROR] File "%s" does not contain valid CSV. ', format: ' [ERROR] File "%s" does not contain valid CSV. ',
stream_get_meta_data($file)['uri'] ?? 'unknown' /** @phpstan-ignore-next-line - URI is always set for fopen() resources. */
values: stream_get_meta_data(stream: $file)['uri'] ?: 'unknown'
), ),
'' ''
]); ]);
return []; return null;
} else { }
/** @var array<string, int> */
$headers = array_flip($headers); $headers = array_flip($headers);
}
foreach ($options as $option => $value) { $callback = function (string $column) use ($headers): ?int {
$columns[$option] = $headers[$value] ?? null; return array_key_exists($column, $headers) ? $headers[$column] : null;
} };
$columns = array_map($callback, $columns);
if (!isset($columns['idColumn']) || !isset($columns['contentColumn'])) { if (!isset($columns['idColumn']) || !isset($columns['contentColumn'])) {
$output->writeln([ $output->writeln([
'', '',
sprintf( sprintf(
' [ERROR] File "%s" does not contain valid CSV. ', format: ' [ERROR] File "%s" does not contain mandatory columns. ',
stream_get_meta_data($file)['uri'] ?? 'unknown' /** @phpstan-ignore-next-line - URI is always set for fopen() resources. */
values: stream_get_meta_data($file)['uri'] ?: 'unknown'
), ),
'' ''
]); ]);
return []; return null;
} }
return $columns; return $columns;
} }

View File

@ -23,9 +23,6 @@ declare(strict_types=1);
namespace OCC\OaiPmh2\Console; namespace OCC\OaiPmh2\Console;
use 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\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
@ -40,7 +37,7 @@ use Symfony\Component\Console\Output\OutputInterface;
*/ */
#[AsCommand( #[AsCommand(
name: 'oai:records:delete', name: 'oai:records:delete',
description: 'Delete a record from database' description: 'Delete a record while obeying deleted record policy'
)] )]
class DeleteRecordCommand extends Console class DeleteRecordCommand extends Console
{ {
@ -74,28 +71,24 @@ class DeleteRecordCommand extends Console
*/ */
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
/** @var array<string, string> */ if (!$this->validateInput(input: $input, output: $output)) {
$arguments = $input->getArguments(); return Command::INVALID;
$entityManager = Database::getInstance()->getEntityManager(); }
$format = $entityManager->getReference(Format::class, $arguments['format']); $record = $this->em->getRecord(
$record = $entityManager->find( identifier: $this->arguments['identifier'],
Record::class, format: $this->arguments['format']
[
'identifier' => $arguments['identifier'],
'format' => $format
]
); );
if (isset($record)) { if (isset($record)) {
Database::getInstance()->deleteRecord($record); $this->em->delete(entity: $record);
$this->clearResultCache(); $this->clearResultCache();
$output->writeln([ $output->writeln([
'', '',
sprintf( sprintf(
' [OK] Record "%s" with metadata prefix "%s" successfully deleted. ', ' [OK] Record "%s" with metadata prefix "%s" successfully (marked as) deleted. ',
$arguments['identifier'], $this->arguments['identifier'],
$arguments['format'] $this->arguments['format']
), ),
'' ''
]); ]);
@ -105,8 +98,8 @@ class DeleteRecordCommand extends Console
'', '',
sprintf( sprintf(
' [ERROR] Record "%s" with metadata prefix "%s" not found. ', ' [ERROR] Record "%s" with metadata prefix "%s" not found. ',
$arguments['identifier'], $this->arguments['identifier'],
$arguments['format'] $this->arguments['format']
), ),
'' ''
]); ]);

View File

@ -24,7 +24,6 @@ namespace OCC\OaiPmh2\Console;
use OCC\OaiPmh2\Configuration; use OCC\OaiPmh2\Configuration;
use OCC\OaiPmh2\Console; use OCC\OaiPmh2\Console;
use OCC\OaiPmh2\Database;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
@ -54,7 +53,7 @@ class PruneDeletedRecordsCommand extends Console
'force', 'force',
'f', 'f',
InputOption::VALUE_NONE, InputOption::VALUE_NONE,
'Deletes records even under "transient" policy.' 'Optional: Deletes records even under "transient" policy.'
); );
parent::configure(); parent::configure();
} }
@ -75,13 +74,13 @@ class PruneDeletedRecordsCommand extends Console
$policy === 'no' $policy === 'no'
or ($policy === 'transient' && $forced) or ($policy === 'transient' && $forced)
) { ) {
$deleted = Database::getInstance()->pruneDeletedRecords(); $deleted = $this->em->pruneDeletedRecords();
$this->clearResultCache(); $this->clearResultCache();
$output->writeln([ $output->writeln([
'', '',
sprintf( sprintf(
' [OK] %d records are deleted and were successfully removed! ', format: ' [OK] %d deleted records were successfully removed! ',
$deleted values: $deleted
), ),
'' ''
]); ]);

View File

@ -23,7 +23,6 @@ declare(strict_types=1);
namespace OCC\OaiPmh2\Console; namespace OCC\OaiPmh2\Console;
use OCC\OaiPmh2\Console; use OCC\OaiPmh2\Console;
use OCC\OaiPmh2\Database;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
@ -51,12 +50,12 @@ class PruneResumptionTokensCommand extends Console
*/ */
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
$expired = Database::getInstance()->pruneResumptionTokens(); $expired = $this->em->pruneExpiredTokens();
$output->writeln([ $output->writeln([
'', '',
sprintf( sprintf(
' [OK] %d resumption tokens are expired and were successfully deleted! ', format: ' [OK] %d expired resumption tokens were successfully deleted! ',
$expired values: $expired
), ),
'' ''
]); ]);

View File

@ -24,7 +24,6 @@ namespace OCC\OaiPmh2\Console;
use OCC\OaiPmh2\Configuration; use OCC\OaiPmh2\Configuration;
use OCC\OaiPmh2\Console; use OCC\OaiPmh2\Console;
use OCC\OaiPmh2\Database;
use OCC\OaiPmh2\Entity\Format; use OCC\OaiPmh2\Entity\Format;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
@ -57,56 +56,55 @@ class UpdateFormatsCommand extends Console
/** @var array<string, array<string, string>> */ /** @var array<string, array<string, string>> */
$formats = Configuration::getInstance()->metadataPrefix; $formats = Configuration::getInstance()->metadataPrefix;
$this->clearResultCache(); $this->clearResultCache();
$inDatabase = Database::getInstance() $inDatabase = $this->em->getMetadataFormats();
->getMetadataFormats()
->getQueryResult();
$added = 0;
$deleted = 0;
$failure = false; $failure = false;
foreach ($formats as $prefix => $format) { foreach ($formats as $prefix => $format) {
if (array_key_exists($prefix, $inDatabase)) {
if ( if (
$format['namespace'] === $inDatabase[$prefix]->getNamespace() $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() and $format['schema'] === $inDatabase[$prefix]->getSchema()
) { ) {
continue; continue;
} }
}
try { try {
$format = new Format($prefix, $format['namespace'], $format['schema']); $format = new Format(
Database::getInstance()->addOrUpdateMetadataFormat($format); prefix: $prefix,
++$added; namespace: $format['namespace'],
schema: $format['schema']
);
$this->em->addOrUpdate(entity: $format);
$output->writeln([ $output->writeln([
sprintf( sprintf(
' [OK] Metadata format "%s" added or updated successfully! ', format: ' [OK] Metadata format "%s" added or updated successfully! ',
$prefix values: $prefix
) )
]); ]);
} catch (ValidationFailedException $exception) { } catch (ValidationFailedException $exception) {
$failure = true; $failure = true;
$output->writeln([ $output->writeln([
sprintf( sprintf(
' [ERROR] Could not add or update metadata format "%s". ', format: ' [ERROR] Could not add or update metadata format "%s". ',
$prefix values: $prefix
), ),
$exception->getMessage() $exception->getMessage()
]); ]);
} }
} }
foreach (array_keys($inDatabase) as $prefix) { foreach (array_diff($inDatabase->getKeys(), array_keys($formats)) as $prefix) {
if (!array_key_exists($prefix, $formats)) { /** @var Format */
Database::getInstance()->deleteMetadataFormat($inDatabase[$prefix]); $format = $inDatabase[$prefix];
++$deleted; $this->em->delete(entity: $format);
$output->writeln([ $output->writeln([
sprintf( sprintf(
' [OK] Metadata format "%s" and all associated records deleted successfully! ', format: ' [OK] Metadata format "%s" and all associated records deleted successfully! ',
$prefix values: $prefix
) )
]); ]);
} }
}
$this->clearResultCache(); $this->clearResultCache();
$currentFormats = array_keys(Database::getInstance()->getMetadataFormats()->getQueryResult()); $currentFormats = $this->em->getMetadataFormats()->getKeys();
if (count($currentFormats) > 0) { if (count($currentFormats) > 0) {
$output->writeln( $output->writeln(
[ [
@ -118,7 +116,7 @@ class UpdateFormatsCommand extends Console
' command "php bin/cli oai:formats:update" again! ', ' command "php bin/cli oai:formats:update" again! ',
'' ''
], ],
1 | 16 OutputInterface::OUTPUT_NORMAL | OutputInterface::VERBOSITY_QUIET
); );
} else { } else {
$output->writeln( $output->writeln(
@ -129,13 +127,9 @@ class UpdateFormatsCommand extends Console
' command "php bin/cli oai:formats:update" again! ', ' command "php bin/cli oai:formats:update" again! ',
'' ''
], ],
1 | 16 OutputInterface::OUTPUT_NORMAL | OutputInterface::VERBOSITY_QUIET
); );
} }
if (!$failure) { return $failure ? Command::FAILURE : Command::SUCCESS;
return Command::SUCCESS;
} else {
return Command::FAILURE;
}
} }
} }

View File

@ -1,573 +0,0 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2024 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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 <sebastian.meyer@opencultureconsulting.com>
* @package OAIPMH2
*
* @template Formats of array<string, Format>
* @template Records of array<string, Record>
* @template Sets of array<string, Set>
*/
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<Sets> 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<Formats> 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<Records> 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<Sets> 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);
}
}

View File

@ -22,9 +22,10 @@ declare(strict_types=1);
namespace OCC\OaiPmh2; 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\Exception\ValidationFailedException;
use Symfony\Component\Validator\Validation;
/** /**
* Base class for all Doctrine/ORM entities. * Base class for all Doctrine/ORM entities.
@ -45,17 +46,13 @@ abstract class Entity
*/ */
protected function validateUrl(string $url): string protected function validateUrl(string $url): string
{ {
$url = trim($url); $url = trim(string: $url);
$validator = Validation::createValidator(); $violations = UrlValidator::validate(url: $url);
$violations = $validator->validate(
$url,
[
new Assert\Url(),
new Assert\NotBlank()
]
);
if ($violations->count() > 0) { if ($violations->count() > 0) {
throw new ValidationFailedException(null, $violations); throw new ValidationFailedException(
value: null,
violations: $violations
);
} }
return $url; return $url;
} }
@ -72,18 +69,12 @@ abstract class Entity
*/ */
protected function validateRegEx(string $string, string $regEx): string protected function validateRegEx(string $string, string $regEx): string
{ {
$validator = Validation::createValidator(); $violations = RegExValidator::validate(string: $string, regEx: $regEx);
$violations = $validator->validate(
$string,
[
new Assert\Regex([
'pattern' => $regEx,
'message' => 'This value does not match the regular expression "{{ pattern }}".'
])
]
);
if ($violations->count() > 0) { if ($violations->count() > 0) {
throw new ValidationFailedException(null, $violations); throw new ValidationFailedException(
value: null,
violations: $violations
);
} }
return $string; return $string;
} }
@ -99,19 +90,12 @@ abstract class Entity
*/ */
protected function validateXml(string $xml): string protected function validateXml(string $xml): string
{ {
$validator = Validation::createValidator(); $violations = XmlValidator::validate(xml: $xml);
$violations = $validator->validate( if ($violations->count() > 0) {
$xml, throw new ValidationFailedException(
[ value: null,
new Assert\Type('string'), violations: $violations
new Assert\NotBlank()
]
); );
if (
$violations->count() > 0
or simplexml_load_string($xml) === false
) {
throw new ValidationFailedException(null, $violations);
} }
return $xml; return $xml;
} }

View File

@ -22,8 +22,11 @@ declare(strict_types=1);
namespace OCC\OaiPmh2\Entity; namespace OCC\OaiPmh2\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use OCC\OaiPmh2\Entity; use OCC\OaiPmh2\Entity;
use OCC\OaiPmh2\Repository\FormatRepository;
use Symfony\Component\Validator\Exception\ValidationFailedException; use Symfony\Component\Validator\Exception\ValidationFailedException;
/** /**
@ -32,9 +35,9 @@ use Symfony\Component\Validator\Exception\ValidationFailedException;
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com> * @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package OAIPMH2 * @package OAIPMH2
*/ */
#[ORM\Entity] #[ORM\Entity(repositoryClass: FormatRepository::class)]
#[ORM\Table(name: 'formats')] #[ORM\Table(name: 'formats')]
class Format extends Entity final class Format extends Entity
{ {
/** /**
* The unique metadata prefix. * The unique metadata prefix.
@ -56,7 +59,21 @@ class Format extends Entity
private string $xmlSchema; private string $xmlSchema;
/** /**
* Get the format's namespace URI. * The format's associated records.
*
* @var Collection<string, Record>
*/
#[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 * @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<string, Record> The collection of records
*/
public function getRecords(): Collection
{
return $this->records;
}
/**
* Get the schema URL for this format.
* *
* @return string The schema URL * @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 * @param string $namespace The namespace URI
* *
@ -97,14 +124,14 @@ class Format extends Entity
public function setNamespace(string $namespace): void public function setNamespace(string $namespace): void
{ {
try { try {
$this->namespace = $this->validateUrl($namespace); $this->namespace = $this->validateUrl(url: $namespace);
} catch (ValidationFailedException $exception) { } catch (ValidationFailedException $exception) {
throw $exception; throw $exception;
} }
} }
/** /**
* Set the format's schema URL. * Set the schema URL for this format.
* *
* @param string $schema The schema URL * @param string $schema The schema URL
* *
@ -115,7 +142,7 @@ class Format extends Entity
public function setSchema(string $schema): void public function setSchema(string $schema): void
{ {
try { try {
$this->xmlSchema = $this->validateUrl($schema); $this->xmlSchema = $this->validateUrl(url: $schema);
} catch (ValidationFailedException $exception) { } catch (ValidationFailedException $exception) {
throw $exception; throw $exception;
} }
@ -133,9 +160,13 @@ class Format extends Entity
public function __construct(string $prefix, string $namespace, string $schema) public function __construct(string $prefix, string $namespace, string $schema)
{ {
try { try {
$this->prefix = $this->validateRegEx($prefix, '/^[A-Za-z0-9\-_\.!~\*\'\(\)]+$/'); $this->prefix = $this->validateRegEx(
$this->setNamespace($namespace); string: $prefix,
$this->setSchema($schema); regEx: '/^[A-Za-z0-9\-_\.!~\*\'\(\)]+$/'
);
$this->setNamespace(namespace: $namespace);
$this->setSchema(schema: $schema);
$this->records = new ArrayCollection();
} catch (ValidationFailedException $exception) { } catch (ValidationFailedException $exception) {
throw $exception; throw $exception;
} }

View File

@ -27,6 +27,7 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use OCC\OaiPmh2\Entity; use OCC\OaiPmh2\Entity;
use OCC\OaiPmh2\Repository\RecordRepository;
use Symfony\Component\Validator\Exception\ValidationFailedException; use Symfony\Component\Validator\Exception\ValidationFailedException;
/** /**
@ -35,13 +36,13 @@ use Symfony\Component\Validator\Exception\ValidationFailedException;
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com> * @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package OAIPMH2 * @package OAIPMH2
*/ */
#[ORM\Entity] #[ORM\Entity(repositoryClass: RecordRepository::class)]
#[ORM\Table(name: 'records')] #[ORM\Table(name: 'records')]
#[ORM\Index(name: 'identifier_idx', columns: ['identifier'])] #[ORM\Index(name: 'identifier_idx', columns: ['identifier'])]
#[ORM\Index(name: 'format_idx', columns: ['format'])] #[ORM\Index(name: 'format_idx', columns: ['format'])]
#[ORM\Index(name: 'last_changed_idx', columns: ['last_changed'])] #[ORM\Index(name: 'last_changed_idx', columns: ['last_changed'])]
#[ORM\Index(name: 'format_last_changed_idx', columns: ['format', '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. * The record identifier.
@ -54,8 +55,16 @@ class Record extends Entity
* The associated format. * The associated format.
*/ */
#[ORM\Id] #[ORM\Id]
#[ORM\ManyToOne(targetEntity: Format::class, fetch: 'EXTRA_LAZY')] #[ORM\ManyToOne(
#[ORM\JoinColumn(name: 'format', referencedColumnName: 'prefix')] targetEntity: Format::class,
fetch: 'EXTRA_LAZY',
inversedBy: 'records'
)]
#[ORM\JoinColumn(
name: 'format',
referencedColumnName: 'prefix',
onDelete: 'CASCADE'
)]
private Format $format; private Format $format;
/** /**
@ -75,7 +84,13 @@ class Record extends Entity
* *
* @var Collection<string, Set> * @var Collection<string, Set>
*/ */
#[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\JoinTable(name: 'records_sets')]
#[ORM\JoinColumn(name: 'record_identifier', referencedColumnName: 'identifier')] #[ORM\JoinColumn(name: 'record_identifier', referencedColumnName: 'identifier')]
#[ORM\JoinColumn(name: 'record_format', referencedColumnName: 'format')] #[ORM\JoinColumn(name: 'record_format', referencedColumnName: 'format')]
@ -91,9 +106,9 @@ class Record extends Entity
*/ */
public function addSet(Set $set): void public function addSet(Set $set): void
{ {
if (!$this->sets->contains($set)) { if (!$this->sets->contains(element: $set)) {
$this->sets->add($set); $this->sets->add(element: $set);
$set->addRecord($this); $set->addRecord(record: $this);
} }
} }
@ -146,23 +161,26 @@ class Record extends Entity
*/ */
public function getSet(string $setSpec): ?Set public function getSet(string $setSpec): ?Set
{ {
return $this->sets->get($setSpec); return $this->sets->get(key: $setSpec);
} }
/** /**
* Get a collection of associated sets. * Get a collection of associated sets.
* *
* @return array<string, Set> The associated sets * @return Collection<string, Set> The associated sets
*/ */
public function getSets(): array public function getSets(): Collection
{ {
return $this->sets->toArray(); return $this->sets;
} }
/** /**
* Whether this record has any content. * Whether this record has any content.
* *
* @return bool TRUE if content exists, FALSE otherwise * @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 public function hasContent(): bool
{ {
@ -178,9 +196,9 @@ class Record extends Entity
*/ */
public function removeSet(Set $set): void public function removeSet(Set $set): void
{ {
if ($this->sets->contains($set)) { if ($this->sets->contains(element: $set)) {
$this->sets->removeElement($set); $this->sets->removeElement(element: $set);
$set->removeRecord($this); $set->removeRecord(record: $this);
} }
} }
@ -200,7 +218,7 @@ class Record extends Entity
$data = trim($data); $data = trim($data);
if ($validate) { if ($validate) {
try { try {
$data = $this->validateXml($data); $data = $this->validateXml(xml: $data);
} catch (ValidationFailedException $exception) { } catch (ValidationFailedException $exception) {
throw $exception; throw $exception;
} }
@ -250,13 +268,13 @@ class Record extends Entity
{ {
try { try {
$this->identifier = $this->validateRegEx( $this->identifier = $this->validateRegEx(
$identifier, string: $identifier,
// xs:anyURI // 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->setFormat(format: $format);
$this->setContent($data); $this->setContent(data: $data);
$this->setLastChanged($lastChanged); $this->setLastChanged(dateTime: $lastChanged);
$this->sets = new ArrayCollection(); $this->sets = new ArrayCollection();
} catch (ValidationFailedException $exception) { } catch (ValidationFailedException $exception) {
throw $exception; throw $exception;

View File

@ -26,6 +26,7 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use OCC\OaiPmh2\Entity; use OCC\OaiPmh2\Entity;
use OCC\OaiPmh2\Repository\SetRepository;
use Symfony\Component\Validator\Exception\ValidationFailedException; use Symfony\Component\Validator\Exception\ValidationFailedException;
/** /**
@ -34,9 +35,9 @@ use Symfony\Component\Validator\Exception\ValidationFailedException;
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com> * @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package OAIPMH2 * @package OAIPMH2
*/ */
#[ORM\Entity] #[ORM\Entity(repositoryClass: SetRepository::class)]
#[ORM\Table(name: 'sets')] #[ORM\Table(name: 'sets')]
class Set extends Entity final class Set extends Entity
{ {
/** /**
* The unique set spec. * The unique set spec.
@ -60,9 +61,14 @@ class Set extends Entity
/** /**
* Collection of associated records. * Collection of associated records.
* *
* @var Collection<int, Record> * @var Collection<string, Record>
*/ */
#[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; private Collection $records;
/** /**
@ -74,9 +80,9 @@ class Set extends Entity
*/ */
public function addRecord(Record $record): void public function addRecord(Record $record): void
{ {
if (!$this->records->contains($record)) { if (!$this->records->contains(element: $record)) {
$this->records->add($record); $this->records->add(element: $record);
$record->addSet($this); $record->addSet(set: $this);
} }
} }
@ -113,17 +119,20 @@ class Set extends Entity
/** /**
* Get a collection of associated records. * Get a collection of associated records.
* *
* @return array<int, Record> The associated records * @return Collection<string, Record> The associated records
*/ */
public function getRecords(): array public function getRecords(): Collection
{ {
return $this->records->toArray(); return $this->records;
} }
/** /**
* Whether this set has a description. * Whether this set has a description.
* *
* @return bool TRUE if description exists, FALSE otherwise * @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 public function hasDescription(): bool
{ {
@ -149,16 +158,16 @@ class Set extends Entity
*/ */
public function removeRecord(Record $record): void public function removeRecord(Record $record): void
{ {
if ($this->records->contains($record)) { if ($this->records->contains(element: $record)) {
$this->records->removeElement($record); $this->records->removeElement(element: $record);
$record->removeSet($this); $record->removeSet(set: $this);
} }
} }
/** /**
* Set the description for this set. * Set the description for this set.
* *
* @param ?string $description The description * @param ?string $description The description XML or NULL to unset
* *
* @return void * @return void
* *
@ -169,7 +178,7 @@ class Set extends Entity
if (isset($description)) { if (isset($description)) {
$description = trim($description); $description = trim($description);
try { try {
$description = $this->validateXml($description); $description = $this->validateXml(xml: $description);
} catch (ValidationFailedException $exception) { } catch (ValidationFailedException $exception) {
throw $exception; throw $exception;
} }
@ -198,15 +207,15 @@ class Set extends Entity
* *
* @throws ValidationFailedException * @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 { try {
$this->spec = $this->validateRegEx( $this->spec = $this->validateRegEx(
$spec, string: $spec,
'/^([A-Za-z0-9\-_\.!~\*\'\(\)])+(:[A-Za-z0-9\-_\.!~\*\'\(\)]+)*$/' regEx: '/^([A-Za-z0-9\-_\.!~\*\'\(\)])+(:[A-Za-z0-9\-_\.!~\*\'\(\)]+)*$/'
); );
$this->setName($name); $this->setName(name: $name);
$this->setDescription($description); $this->setDescription(description: $description);
$this->records = new ArrayCollection(); $this->records = new ArrayCollection();
} catch (ValidationFailedException $exception) { } catch (ValidationFailedException $exception) {
throw $exception; throw $exception;

View File

@ -27,17 +27,20 @@ use DateTime;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use OCC\OaiPmh2\Configuration; use OCC\OaiPmh2\Configuration;
use OCC\OaiPmh2\Entity; use OCC\OaiPmh2\Entity;
use OCC\OaiPmh2\Repository\TokenRepository;
/** /**
* Doctrine/ORM Entity for resumption tokens. * Doctrine/ORM Entity for resumption tokens.
* *
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com> * @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package OAIPMH2 * @package OAIPMH2
*
* @psalm-import-type OaiRequestMetadata from \OCC\OaiPmh2\Middleware
*/ */
#[ORM\Entity] #[ORM\Entity(repositoryClass: TokenRepository::class)]
#[ORM\Table(name: 'tokens')] #[ORM\Table(name: 'tokens')]
#[ORM\Index(name: 'valid_until_idx', columns: ['valid_until'])] #[ORM\Index(name: 'valid_until_idx', columns: ['valid_until'])]
class Token extends Entity final class Token extends Entity
{ {
/** /**
* The resumption token. * The resumption token.
@ -77,11 +80,11 @@ class Token extends Entity
/** /**
* Get the query parameters. * Get the query parameters.
* *
* @return array<string, int|string|null> The query parameters * @return OaiRequestMetadata The query parameters
*/ */
public function getParameters(): array public function getParameters(): array
{ {
/** @var array<string, int|string|null> */ /** @var OaiRequestMetadata */
return unserialize($this->parameters); return unserialize($this->parameters);
} }
@ -109,15 +112,15 @@ class Token extends Entity
* Get new entity of resumption token. * Get new entity of resumption token.
* *
* @param string $verb The verb for which the token is issued * @param string $verb The verb for which the token is issued
* @param array<string, int|string|null> $parameters The query parameters * @param OaiRequestMetadata $parameters The query parameters
*/ */
public function __construct(string $verb, array $parameters) public function __construct(string $verb, array $parameters)
{ {
$this->token = substr(md5(microtime()), 0, 8); $this->token = substr(md5(microtime()), 0, 8);
$this->verb = $verb; $this->verb = $verb;
$this->parameters = serialize($parameters); $this->parameters = serialize($parameters);
$validity = new DateTime(); $validUntil = new DateTime();
$validity->add(new DateInterval('PT' . Configuration::getInstance()->tokenValid . 'S')); $validUntil->add(interval: new DateInterval(duration: 'PT' . Configuration::getInstance()->tokenValid . 'S'));
$this->validUntil = $validity; $this->validUntil = $validUntil;
} }
} }

471
src/EntityManager.php Normal file
View File

@ -0,0 +1,471 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2024 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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 <sebastian.meyer@opencultureconsulting.com>
* @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<Format> 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<Record> 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<string, Record> */
$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<Set> 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<string, Set> */
$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));
}
}

View File

@ -22,7 +22,9 @@ declare(strict_types=1);
namespace OCC\OaiPmh2; namespace OCC\OaiPmh2;
use DOMElement;
use GuzzleHttp\Psr7\Utils; use GuzzleHttp\Psr7\Utils;
use OCC\OaiPmh2\Entity\Token;
use OCC\OaiPmh2\Middleware\ErrorHandler; use OCC\OaiPmh2\Middleware\ErrorHandler;
use OCC\PSR15\AbstractMiddleware; use OCC\PSR15\AbstractMiddleware;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
@ -33,13 +35,99 @@ use Psr\Http\Message\ServerRequestInterface;
* *
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com> * @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package OAIPMH2 * @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 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. * 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. * Prepare response document.
@ -59,7 +147,10 @@ abstract class Middleware extends AbstractMiddleware
*/ */
protected function processRequest(ServerRequestInterface $request): ServerRequestInterface 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; return $request;
} }
@ -73,18 +164,22 @@ abstract class Middleware extends AbstractMiddleware
protected function processResponse(ResponseInterface $response): ResponseInterface protected function processResponse(ResponseInterface $response): ResponseInterface
{ {
if (!ErrorHandler::getInstance()->hasErrors() && isset($this->preparedResponse)) { 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; return $response;
} }
/** /**
* The constructor must have the same signature for all derived classes, thus make it final. * The constructor must have the same signature for all derived classes, thus make it final.
*
* @see https://psalm.dev/229
*/ */
final public function __construct() final public function __construct()
{ {
// Make constructor final to avoid issues in dispatcher. $this->em = EntityManager::getInstance();
// @see https://psalm.dev/229
} }
} }

View File

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace OCC\OaiPmh2\Middleware; namespace OCC\OaiPmh2\Middleware;
use OCC\OaiPmh2\EntityManager;
use OCC\OaiPmh2\Middleware; use OCC\OaiPmh2\Middleware;
use OCC\PSR15\AbstractMiddleware; use OCC\PSR15\AbstractMiddleware;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
@ -37,6 +38,8 @@ class Dispatcher extends AbstractMiddleware
{ {
/** /**
* List of defined OAI-PMH parameters. * List of defined OAI-PMH parameters.
*
* @var string[]
*/ */
protected const OAI_PARAMS = [ protected const OAI_PARAMS = [
'verb', 'verb',
@ -62,14 +65,14 @@ class Dispatcher extends AbstractMiddleware
/** @var array<string, string> */ /** @var array<string, string> */
$arguments = $request->getQueryParams(); $arguments = $request->getQueryParams();
} elseif ($request->getMethod() === 'POST') { } 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<string, string> */ /** @var array<string, string> */
$arguments = (array) $request->getParsedBody(); $arguments = (array) $request->getParsedBody();
} }
} }
if ($this->validateArguments($arguments)) { if ($this->validateArguments(arguments: $arguments)) {
foreach ($arguments as $param => $value) { foreach ($arguments as $param => $value) {
$request = $request->withAttribute($param, $value); $request = $request->withAttribute(name: $param, value: $value);
} }
} }
return $request; return $request;
@ -84,17 +87,17 @@ class Dispatcher extends AbstractMiddleware
*/ */
protected function processRequest(ServerRequestInterface $request): ServerRequestInterface protected function processRequest(ServerRequestInterface $request): ServerRequestInterface
{ {
$request = $this->getRequestWithAttributes($request); $request = $this->getRequestWithAttributes(request: $request);
$errorHandler = ErrorHandler::getInstance(); $errorHandler = ErrorHandler::getInstance();
if (!$errorHandler->hasErrors()) { if (!$errorHandler->hasErrors()) {
/** @var string */ /** @var string */
$verb = $request->getAttribute('verb'); $verb = $request->getAttribute(name: 'verb');
$middleware = __NAMESPACE__ . '\\' . $verb; $middleware = __NAMESPACE__ . '\\' . $verb;
if (is_a($middleware, Middleware::class, true)) { if (is_a(object_or_class: $middleware, class: Middleware::class, allow_string: true)) {
$this->requestHandler->queue->enqueue(new $middleware()); $this->requestHandler->queue->enqueue(value: new $middleware());
} }
} }
$this->requestHandler->queue->enqueue($errorHandler); $this->requestHandler->queue->enqueue(value: $errorHandler);
return $request; return $request;
} }
@ -110,7 +113,7 @@ class Dispatcher extends AbstractMiddleware
// TODO: Add support for content compression // TODO: Add support for content compression
// https://openarchives.org/OAI/openarchivesprotocol.html#ResponseCompression // https://openarchives.org/OAI/openarchivesprotocol.html#ResponseCompression
// https://github.com/middlewares/encoder // https://github.com/middlewares/encoder
return $response->withHeader('Content-Type', 'text/xml'); return $response->withHeader(name: 'Content-Type', value: 'text/xml');
} }
/** /**
@ -124,30 +127,104 @@ class Dispatcher extends AbstractMiddleware
*/ */
protected function validateArguments(array $arguments): bool protected function validateArguments(array $arguments): bool
{ {
$errorHandler = ErrorHandler::getInstance();
if ( if (
count(array_diff(array_keys($arguments), self::OAI_PARAMS)) !== 0 count(array_diff(array_keys($arguments), self::OAI_PARAMS)) !== 0
or !isset($arguments['verb']) or !isset($arguments['verb'])
) { ) {
$errorHandler->withError('badArgument'); ErrorHandler::getInstance()->withError(errorCode: 'badArgument');
} else { } else {
switch ($arguments['verb']) { match ($arguments['verb']) {
case 'GetRecord': '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 ( if (
count($arguments) !== 3 count($arguments) !== 3
or !isset($arguments['identifier']) or !isset($arguments['identifier'])
or !isset($arguments['metadataPrefix']) or !isset($arguments['metadataPrefix'])
) { ) {
$errorHandler->withError('badArgument'); ErrorHandler::getInstance()->withError(errorCode: 'badArgument');
} }
break; }
case 'Identify':
/**
* Validate request arguments for verb Identify.
*
* @param string[] $arguments The request parameters
*
* @return void
*/
protected function validateIdentify(array $arguments): void
{
if (count($arguments) !== 1) { if (count($arguments) !== 1) {
$errorHandler->withError('badArgument'); ErrorHandler::getInstance()->withError(errorCode: 'badArgument');
} }
break; }
case 'ListIdentifiers':
case 'ListRecords': /**
* 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 ( if (
isset($arguments['metadataPrefix']) isset($arguments['metadataPrefix'])
xor isset($arguments['resumptionToken']) xor isset($arguments['resumptionToken'])
@ -156,30 +233,60 @@ class Dispatcher extends AbstractMiddleware
(isset($arguments['resumptionToken']) && count($arguments) !== 2) (isset($arguments['resumptionToken']) && count($arguments) !== 2)
or isset($arguments['identifier']) or isset($arguments['identifier'])
) { ) {
$errorHandler->withError('badArgument'); ErrorHandler::getInstance()->withError(errorCode: 'badArgument');
} }
} else { } else {
$errorHandler->withError('badArgument'); ErrorHandler::getInstance()->withError(errorCode: 'badArgument');
}
break;
case 'ListMetadataFormats':
if (count($arguments) !== 1) {
if (!isset($arguments['identifier']) || count($arguments) !== 2) {
$errorHandler->withError('badArgument');
} }
} }
break;
case 'ListSets': /**
* 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 (count($arguments) !== 1) {
if (!isset($arguments['resumptionToken']) || count($arguments) !== 2) { if (!isset($arguments['resumptionToken']) || count($arguments) !== 2) {
$errorHandler->withError('badArgument'); ErrorHandler::getInstance()->withError(errorCode: 'badArgument');
} }
} }
break; }
default:
$errorHandler->withError('badVerb'); /**
* 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();
} }
} }

View File

@ -25,7 +25,7 @@ namespace OCC\OaiPmh2\Middleware;
use DomainException; use DomainException;
use GuzzleHttp\Psr7\Utils; use GuzzleHttp\Psr7\Utils;
use OCC\Basics\Traits\Singleton; use OCC\Basics\Traits\Singleton;
use OCC\OaiPmh2\Document; use OCC\OaiPmh2\Response;
use OCC\PSR15\AbstractMiddleware; use OCC\PSR15\AbstractMiddleware;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface; use Psr\Http\Message\StreamInterface;
@ -48,7 +48,7 @@ class ErrorHandler extends AbstractMiddleware
protected const OAI_ERRORS = [ 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.', '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.', '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.', '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.', '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.', '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 protected function getResponseBody(): StreamInterface
{ {
$document = new Document($this->requestHandler->request); $response = new Response(serverRequest: $this->requestHandler->request);
foreach (array_unique($this->errors) as $errorCode) { foreach (array_unique($this->errors) as $errorCode) {
$error = $document->createElement('error', self::OAI_ERRORS[$errorCode], true); $error = $response->createElement(
$error->setAttribute('code', $errorCode); 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 protected function processResponse(ResponseInterface $response): ResponseInterface
{ {
if ($this->hasErrors()) { if ($this->hasErrors()) {
$response = $response->withBody($this->getResponseBody()); $response = $response->withBody(body: $this->getResponseBody());
} }
return $response; return $response;
} }
@ -118,11 +125,11 @@ class ErrorHandler extends AbstractMiddleware
$this->errors[] = $errorCode; $this->errors[] = $errorCode;
} else { } else {
throw new DomainException( throw new DomainException(
sprintf( message: sprintf(
'Valid OAI-PMH error code expected, "%s" given.', format: 'Valid OAI-PMH error code expected, "%s" given.',
$errorCode values: $errorCode
), ),
500 code: 500
); );
} }
return $this; return $this;

View File

@ -22,14 +22,13 @@ declare(strict_types=1);
namespace OCC\OaiPmh2\Middleware; namespace OCC\OaiPmh2\Middleware;
use OCC\OaiPmh2\Database;
use OCC\OaiPmh2\Document;
use OCC\OaiPmh2\Entity\Format;
use OCC\OaiPmh2\Middleware; use OCC\OaiPmh2\Middleware;
use OCC\OaiPmh2\Response;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
/** /**
* Process the "GetRecord" request. * Process the "GetRecord" request.
*
* @see https://www.openarchives.org/OAI/openarchivesprotocol.html#GetRecord * @see https://www.openarchives.org/OAI/openarchivesprotocol.html#GetRecord
* *
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com> * @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
@ -46,54 +45,67 @@ class GetRecord extends Middleware
*/ */
protected function prepareResponse(ServerRequestInterface $request): void protected function prepareResponse(ServerRequestInterface $request): void
{ {
/** @var array<string, string> */ $oaiRecord = $this->em->getRecord(
$params = $request->getAttributes(); identifier: (string) $this->arguments['identifier'],
/** @var Format */ format: (string) $this->arguments['metadataPrefix']
$format = Database::getInstance()->getEntityManager()->getReference(Format::class, $params['metadataPrefix']); );
$oaiRecord = Database::getInstance()->getRecord($params['identifier'], $format);
if (!isset($oaiRecord)) { if (!isset($oaiRecord)) {
if (Database::getInstance()->idDoesExist($params['identifier'])) { if ($this->em->isValidRecordIdentifier(identifier: (string) $this->arguments['identifier'])) {
ErrorHandler::getInstance()->withError('cannotDisseminateFormat'); ErrorHandler::getInstance()->withError(errorCode: 'cannotDisseminateFormat');
} else { } else {
ErrorHandler::getInstance()->withError('idDoesNotExist'); ErrorHandler::getInstance()->withError(errorCode: 'idDoesNotExist');
} }
return; return;
} else {
$oaiRecordContent = $oaiRecord->getContent();
} }
$document = new Document($request); $response = new Response(serverRequest: $request);
$getRecord = $document->createElement('GetRecord', '', true); $getRecord = $response->createElement(
localName: 'GetRecord',
value: '',
appendToRoot: true
);
$record = $document->createElement('record'); $record = $response->createElement(localName: 'record');
$getRecord->appendChild($record); $getRecord->appendChild(node: $record);
$header = $document->createElement('header'); $header = $response->createElement(localName: 'header');
if (!isset($oaiRecordContent)) { if (!$oaiRecord->hasContent()) {
$header->setAttribute('status', 'deleted'); $header->setAttribute(
qualifiedName: 'status',
value: 'deleted'
);
} }
$record->appendChild($header); $record->appendChild(node: $header);
$identifier = $document->createElement('identifier', $oaiRecord->getIdentifier()); $identifier = $response->createElement(
$header->appendChild($identifier); localName: 'identifier',
value: $oaiRecord->getIdentifier()
);
$header->appendChild(node: $identifier);
$datestamp = $document->createElement('datestamp', $oaiRecord->getLastChanged()->format('Y-m-d\TH:i:s\Z')); $datestamp = $response->createElement(
$header->appendChild($datestamp); localName: 'datestamp',
value: $oaiRecord->getLastChanged()->format(format: 'Y-m-d\TH:i:s\Z')
);
$header->appendChild(node: $datestamp);
foreach ($oaiRecord->getSets() as $set) { foreach ($oaiRecord->getSets() as $set) {
$setSpec = $document->createElement('setSpec', $set->getName()); $setSpec = $response->createElement(
$header->appendChild($setSpec); localName: 'setSpec',
value: $set->getName()
);
$header->appendChild(node: $setSpec);
} }
if (isset($oaiRecordContent)) { if ($oaiRecord->hasContent()) {
$metadata = $document->createElement('metadata'); $metadata = $response->createElement(localName: 'metadata');
$record->appendChild($metadata); $record->appendChild(node: $metadata);
$data = $document->importData($oaiRecordContent); $data = $response->importData(data: $oaiRecord->getContent());
$metadata->appendChild($data); $metadata->appendChild(node: $data);
} }
$this->preparedResponse = $document; $this->preparedResponse = $response;
} }
} }

View File

@ -24,13 +24,13 @@ namespace OCC\OaiPmh2\Middleware;
use GuzzleHttp\Psr7\Uri; use GuzzleHttp\Psr7\Uri;
use OCC\OaiPmh2\Configuration; use OCC\OaiPmh2\Configuration;
use OCC\OaiPmh2\Database;
use OCC\OaiPmh2\Document;
use OCC\OaiPmh2\Middleware; use OCC\OaiPmh2\Middleware;
use OCC\OaiPmh2\Response;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
/** /**
* Process the "Identify" request. * Process the "Identify" request.
*
* @see https://www.openarchives.org/OAI/openarchivesprotocol.html#Identify * @see https://www.openarchives.org/OAI/openarchivesprotocol.html#Identify
* *
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com> * @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
@ -47,47 +47,75 @@ class Identify extends Middleware
*/ */
protected function prepareResponse(ServerRequestInterface $request): void protected function prepareResponse(ServerRequestInterface $request): void
{ {
$document = new Document($request); $response = new Response(serverRequest: $request);
$identify = $document->createElement('Identify', '', true); $identify = $response->createElement(
localName: 'Identify',
value: '',
appendToRoot: true
);
$name = Configuration::getInstance()->repositoryName; $repositoryName = $response->createElement(
$repositoryName = $document->createElement('repositoryName', $name); localName: 'repositoryName',
$identify->appendChild($repositoryName); value: Configuration::getInstance()->repositoryName
);
$identify->appendChild(node: $repositoryName);
$uri = Uri::composeComponents( $uri = Uri::composeComponents(
$request->getUri()->getScheme(), scheme: $request->getUri()->getScheme(),
$request->getUri()->getAuthority(), authority: $request->getUri()->getAuthority(),
$request->getUri()->getPath(), path: $request->getUri()->getPath(),
null, query: null,
null fragment: null
); );
$baseURL = $document->createElement('baseURL', $uri); $baseURL = $response->createElement(
$identify->appendChild($baseURL); localName: 'baseURL',
value: $uri
);
$identify->appendChild(node: $baseURL);
$protocolVersion = $document->createElement('protocolVersion', '2.0'); $protocolVersion = $response->createElement(
$identify->appendChild($protocolVersion); localName: 'protocolVersion',
value: '2.0'
);
$identify->appendChild(node: $protocolVersion);
$email = Configuration::getInstance()->adminEmail; $adminEmail = $response->createElement(
$adminEmail = $document->createElement('adminEmail', $email); localName: 'adminEmail',
$identify->appendChild($adminEmail); value: Configuration::getInstance()->adminEmail
);
$identify->appendChild(node: $adminEmail);
$datestamp = Database::getInstance()->getEarliestDatestamp(); $earliestDatestamp = $response->createElement(
$earliestDatestamp = $document->createElement('earliestDatestamp', $datestamp); localName: 'earliestDatestamp',
$identify->appendChild($earliestDatestamp); value: $this->em->getEarliestDatestamp()
);
$identify->appendChild(node: $earliestDatestamp);
$deletedRecord = $document->createElement('deletedRecord', 'transient'); $deletedRecord = $response->createElement(
$identify->appendChild($deletedRecord); localName: 'deletedRecord',
value: Configuration::getInstance()->deletedRecords
);
$identify->appendChild(node: $deletedRecord);
$granularity = $document->createElement('granularity', 'YYYY-MM-DDThh:mm:ssZ'); $granularity = $response->createElement(
$identify->appendChild($granularity); localName: 'granularity',
value: 'YYYY-MM-DDThh:mm:ssZ'
);
$identify->appendChild(node: $granularity);
// TODO: Implement explicit content compression support. // TODO: Implement explicit content compression support.
// $compressionDeflate = $document->createElement('compression', 'deflate'); // $compressionDeflate = $response->createElement(
// $identify->appendChild($compressionDeflate); // localName: 'compression',
// value: 'deflate'
// );
// $identify->appendChild(node: $compressionDeflate);
// $compressionGzip = $document->createElement('compression', 'gzip'); // $compressionGzip = $response->createElement(
// $identify->appendChild($compressionGzip); // localName: 'compression',
// value: 'gzip'
// );
// $identify->appendChild(node: $compressionGzip);
$this->preparedResponse = $document; $this->preparedResponse = $response;
} }
} }

View File

@ -22,16 +22,13 @@ declare(strict_types=1);
namespace OCC\OaiPmh2\Middleware; 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\Middleware;
use OCC\OaiPmh2\Response;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
/** /**
* Process the "ListIdentifiers" request. * Process the "ListIdentifiers" request.
*
* @see https://www.openarchives.org/OAI/openarchivesprotocol.html#ListIdentifiers * @see https://www.openarchives.org/OAI/openarchivesprotocol.html#ListIdentifiers
* *
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com> * @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
@ -40,7 +37,7 @@ use Psr\Http\Message\ServerRequestInterface;
class ListIdentifiers extends Middleware 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 * @param ServerRequestInterface $request The incoming request
* *
@ -48,127 +45,78 @@ class ListIdentifiers extends Middleware
*/ */
protected function prepareResponse(ServerRequestInterface $request): void protected function prepareResponse(ServerRequestInterface $request): void
{ {
$counter = 0; $this->checkResumptionToken();
$completeListSize = 0;
$maxRecords = Configuration::getInstance()->maxRecords;
/** @var array<string, string> */ $records = $this->em->getRecords(
$params = $request->getAttributes(); verb: $this->arguments['verb'],
$verb = $params['verb']; metadataPrefix: (string) $this->arguments['metadataPrefix'],
$metadataPrefix = $params['metadataPrefix'] ?? ''; counter: $this->arguments['counter'],
$from = $params['from'] ?? null; from: $this->arguments['from'],
$until = $params['until'] ?? null; until: $this->arguments['until'],
$set = $params['set'] ?? null; set: $this->arguments['set']
$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
); );
$newToken = $records->getResumptionToken();
if (count($records) === 0) { if (count($records) === 0) {
ErrorHandler::getInstance()->withError('noRecordsMatch'); ErrorHandler::getInstance()->withError(errorCode: 'noRecordsMatch');
return; return;
} elseif (isset($newToken)) {
$completeListSize = $newToken->getParameters()['completeListSize'];
} }
$document = new Document($request); $response = new Response(serverRequest: $request);
$list = $document->createElement($verb, '', true); $list = $response->createElement(
localName: $this->arguments['verb'],
/** @var Record $oaiRecord */ value: '',
foreach ($records as $oaiRecord) { appendToRoot: true
if ($verb === 'ListIdentifiers') { );
$baseNode = $list; $baseNode = $list;
} else {
$record = $document->createElement('record'); foreach ($records as $oaiRecord) {
$list->appendChild($record); if ($this->arguments['verb'] === 'ListRecords') {
$record = $response->createElement(localName: 'record');
$list->appendChild(node: $record);
$baseNode = $record; $baseNode = $record;
} }
$header = $document->createElement('header'); $header = $response->createElement(localName: 'header');
if (!$oaiRecord->hasContent()) { $baseNode->appendChild(node: $header);
$header->setAttribute('status', 'deleted');
}
$baseNode->appendChild($header);
$identifier = $document->createElement('identifier', $oaiRecord->getIdentifier()); $identifier = $response->createElement(
$header->appendChild($identifier); localName: 'identifier',
value: $oaiRecord->getIdentifier()
);
$header->appendChild(node: $identifier);
$datestamp = $document->createElement('datestamp', $oaiRecord->getLastChanged()->format('Y-m-d\TH:i:s\Z')); $datestamp = $response->createElement(
$header->appendChild($datestamp); localName: 'datestamp',
value: $oaiRecord->getLastChanged()->format(format: 'Y-m-d\TH:i:s\Z')
);
$header->appendChild(node: $datestamp);
foreach ($oaiRecord->getSets() as $oaiSet) { foreach ($oaiRecord->getSets() as $oaiSet) {
$setSpec = $document->createElement('setSpec', $oaiSet->getName()); $setSpec = $response->createElement(
$header->appendChild($setSpec); localName: 'setSpec',
} value: $oaiSet->getName()
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')
);
}
$resumptionToken->setAttribute(
'completeListSize',
(string) $completeListSize
);
$resumptionToken->setAttribute(
'cursor',
(string) ($counter * $maxRecords)
); );
$header->appendChild(node: $setSpec);
} }
$this->preparedResponse = $document; 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);
}
}
$this->preparedResponse = $response;
$this->addResumptionToken(
node: $list,
token: $records->getResumptionToken() ?? null
);
} }
} }

View File

@ -22,14 +22,13 @@ declare(strict_types=1);
namespace OCC\OaiPmh2\Middleware; namespace OCC\OaiPmh2\Middleware;
use OCC\OaiPmh2\Database;
use OCC\OaiPmh2\Document;
use OCC\OaiPmh2\Entity\Format;
use OCC\OaiPmh2\Middleware; use OCC\OaiPmh2\Middleware;
use OCC\OaiPmh2\Response;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
/** /**
* Process the "ListMetadataFormats" request. * Process the "ListMetadataFormats" request.
*
* @see https://www.openarchives.org/OAI/openarchivesprotocol.html#ListMetadataFormats * @see https://www.openarchives.org/OAI/openarchivesprotocol.html#ListMetadataFormats
* *
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com> * @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
@ -46,37 +45,50 @@ class ListMetadataFormats extends Middleware
*/ */
protected function prepareResponse(ServerRequestInterface $request): void protected function prepareResponse(ServerRequestInterface $request): void
{ {
/** @var ?string */ $formats = $this->em->getMetadataFormats(recordIdentifier: $this->arguments['identifier']);
$identifier = $request->getAttribute('identifier');
$formats = Database::getInstance()->getMetadataFormats($identifier);
if (count($formats) === 0) { if (count($formats) === 0) {
if (!isset($identifier) || Database::getInstance()->idDoesExist($identifier)) { if (
ErrorHandler::getInstance()->withError('noMetadataFormats'); !isset($this->arguments['identifier'])
|| $this->em->isValidRecordIdentifier(identifier: $this->arguments['identifier'])
) {
ErrorHandler::getInstance()->withError(errorCode: 'noMetadataFormats');
} else { } else {
ErrorHandler::getInstance()->withError('idDoesNotExist'); ErrorHandler::getInstance()->withError(errorCode: 'idDoesNotExist');
} }
return; return;
} }
$document = new Document($request); $response = new Response(serverRequest: $request);
$listMetadataFormats = $document->createElement('ListMetadataFormats', '', true); $listMetadataFormats = $response->createElement(
localName: 'ListMetadataFormats',
value: '',
appendToRoot: true
);
/** @var Format $oaiFormat */
foreach ($formats as $oaiFormat) { foreach ($formats as $oaiFormat) {
$metadataFormat = $document->createElement('metadataFormat'); $metadataFormat = $response->createElement(localName: 'metadataFormat');
$listMetadataFormats->appendChild($metadataFormat); $listMetadataFormats->appendChild(node: $metadataFormat);
$metadataPrefix = $document->createElement('metadataPrefix', $oaiFormat->getPrefix()); $metadataPrefix = $response->createElement(
$metadataFormat->appendChild($metadataPrefix); localName: 'metadataPrefix',
value: $oaiFormat->getPrefix()
);
$metadataFormat->appendChild(node: $metadataPrefix);
$schema = $document->createElement('schema', $oaiFormat->getSchema()); $schema = $response->createElement(
$metadataFormat->appendChild($schema); localName: 'schema',
value: $oaiFormat->getSchema()
);
$metadataFormat->appendChild(node: $schema);
$metadataNamespace = $document->createElement('metadataNamespace', $oaiFormat->getNamespace()); $metadataNamespace = $response->createElement(
$metadataFormat->appendChild($metadataNamespace); localName: 'metadataNamespace',
value: $oaiFormat->getNamespace()
);
$metadataFormat->appendChild(node: $metadataNamespace);
} }
$this->preparedResponse = $document; $this->preparedResponse = $response;
} }
} }

View File

@ -24,6 +24,7 @@ namespace OCC\OaiPmh2\Middleware;
/** /**
* Process the "ListRecords" request. * Process the "ListRecords" request.
*
* @see https://www.openarchives.org/OAI/openarchivesprotocol.html#ListRecords * @see https://www.openarchives.org/OAI/openarchivesprotocol.html#ListRecords
* *
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com> * @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
@ -34,6 +35,6 @@ class ListRecords extends ListIdentifiers
/** /**
* "ListIdentifiers" and "ListRecords" are practically identical except the * "ListIdentifiers" and "ListRecords" are practically identical except the
* former returns the header information only while the latter also returns * former returns the header information only while the latter also returns
* the records' data. * the records' data. Hence this is just a class alias.
*/ */
} }

View File

@ -22,21 +22,17 @@ declare(strict_types=1);
namespace OCC\OaiPmh2\Middleware; 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\Middleware;
use OCC\OaiPmh2\Response;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
/** /**
* Process the "ListSets" request. * Process the "ListSets" request.
*
* @see https://openarchives.org/OAI/openarchivesprotocol.html#ListSets * @see https://openarchives.org/OAI/openarchivesprotocol.html#ListSets
* *
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com> * @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package OAIPMH2 * @package OAIPMH2
*
* @template Sets of array<string, Set>
*/ */
class ListSets extends Middleware class ListSets extends Middleware
{ {
@ -49,78 +45,52 @@ class ListSets extends Middleware
*/ */
protected function prepareResponse(ServerRequestInterface $request): void protected function prepareResponse(ServerRequestInterface $request): void
{ {
$counter = 0; $this->checkResumptionToken();
$completeListSize = 0;
$maxRecords = Configuration::getInstance()->maxRecords;
/** @var ?string */ $sets = $this->em->getSets(counter: $this->arguments['counter']);
$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 = Database::getInstance()->getSets($counter);
$newToken = $sets->getResumptionToken();
if (count($sets) === 0) { if (count($sets) === 0) {
ErrorHandler::getInstance()->withError('noSetHierarchy'); ErrorHandler::getInstance()->withError(errorCode: 'noSetHierarchy');
return; return;
} elseif (isset($newToken)) {
$completeListSize = $newToken->getParameters()['completeListSize'];
} }
$document = new Document($request); $response = new Response(serverRequest: $request);
$list = $document->createElement('ListSets', '', true); $list = $response->createElement(
localName: 'ListSets',
value: '',
appendToRoot: true
);
/** @var Set $oaiSet */
foreach ($sets as $oaiSet) { foreach ($sets as $oaiSet) {
$set = $document->createElement('set'); $set = $response->createElement(localName: 'set');
$list->appendChild($set); $list->appendChild(node: $set);
$setSpec = $document->createElement('setSpec', $oaiSet->getSpec()); $setSpec = $response->createElement(
$set->appendChild($setSpec); localName: 'setSpec',
value: $oaiSet->getSpec()
);
$set->appendChild(node: $setSpec);
$setName = $document->createElement('setName', $oaiSet->getName()); $setName = $response->createElement(
$set->appendChild($setName); localName: 'setName',
value: $oaiSet->getName()
);
$set->appendChild(node: $setName);
if ($oaiSet->hasDescription()) { if ($oaiSet->hasDescription()) {
$setDescription = $document->createElement('setDescription'); $setDescription = $response->createElement(localName: 'setDescription');
$set->appendChild($setDescription); $set->appendChild(node: $setDescription);
/** @var string */ $data = $response->importData(data: $oaiSet->getDescription());
$description = $oaiSet->getDescription(); $setDescription->appendChild(node: $data);
$data = $document->importData($description);
$setDescription->appendChild($data);
} }
} }
if (isset($oldToken) || isset($newToken)) { $this->preparedResponse = $response;
$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 = $document; $this->addResumptionToken(
node: $list,
token: $sets->getResumptionToken() ?? null
);
} }
} }

View File

@ -0,0 +1,72 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2024 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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 <sebastian.meyer@opencultureconsulting.com>
* @package OAIPMH2
*
* @extends EntityRepository<Format>
*/
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();
}
}

View File

@ -0,0 +1,103 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2024 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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 <sebastian.meyer@opencultureconsulting.com>
* @package OAIPMH2
*
* @extends EntityRepository<Record>
*/
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();
}
}
}

View File

@ -0,0 +1,71 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2024 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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 <sebastian.meyer@opencultureconsulting.com>
* @package OAIPMH2
*
* @extends EntityRepository<Set>
*/
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();
}
}

View File

@ -0,0 +1,64 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2024 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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 <sebastian.meyer@opencultureconsulting.com>
* @package OAIPMH2
*
* @extends EntityRepository<Token>
*/
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();
}
}

View File

@ -35,22 +35,17 @@ use Psr\Http\Message\ServerRequestInterface;
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com> * @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package OAIPMH2 * @package OAIPMH2
*/ */
class Document final class Response
{ {
/** /**
* This holds the DOMDocument of the OAI-PMH XML 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. * This holds the root node of the OAI-PMH XML response.
*/ */
protected DOMElement $rootNode; private DOMElement $rootNode;
/**
* This holds the current server request.
*/
protected ServerRequestInterface $serverRequest;
/** /**
* Add XSL processing instructions to XML response document. * Add XSL processing instructions to XML response document.
@ -61,24 +56,24 @@ class Document
{ {
$uri = $this->serverRequest->getUri(); $uri = $this->serverRequest->getUri();
$basePath = $uri->getPath(); $basePath = $uri->getPath();
if (str_ends_with($basePath, 'index.php')) { if (str_ends_with(haystack: $basePath, needle: 'index.php')) {
$basePath = pathinfo($basePath, PATHINFO_DIRNAME); $basePath = pathinfo(path: $basePath, flags: PATHINFO_DIRNAME);
} }
$stylesheet = Uri::composeComponents( $stylesheet = Uri::composeComponents(
$uri->getScheme(), scheme: $uri->getScheme(),
$uri->getAuthority(), authority: $uri->getAuthority(),
rtrim($basePath, '/') . '/resources/stylesheet.xsl', path: rtrim(string: $basePath, characters: '/') . '/resources/stylesheet.xsl',
null, query: null,
null fragment: null
); );
$xslt = $this->dom->createProcessingInstruction( $xslt = $this->dom->createProcessingInstruction(
'xml-stylesheet', target: 'xml-stylesheet',
sprintf( data: sprintf(
'type="text/xsl" href="%s"', format: 'type="text/xsl" href="%s"',
$stylesheet values: $stylesheet
) )
); );
$this->dom->appendChild($xslt); $this->dom->appendChild(node: $xslt);
} }
/** /**
@ -90,20 +85,27 @@ class Document
{ {
$uri = $this->serverRequest->getUri(); $uri = $this->serverRequest->getUri();
$baseUrl = Uri::composeComponents( $baseUrl = Uri::composeComponents(
$uri->getScheme(), scheme: $uri->getScheme(),
$uri->getAuthority(), authority: $uri->getAuthority(),
$uri->getPath(), path: $uri->getPath(),
null, query: null,
null fragment: null
);
$request = $this->createElement(
localName: 'request',
value: $baseUrl,
appendToRoot: true
); );
$request = $this->dom->createElement('request', $baseUrl);
$this->rootNode->appendChild($request);
/** @var array<string, string> */ /** @var array<string, string> */
$params = $this->serverRequest->getAttributes(); $params = $this->serverRequest->getAttributes();
foreach ($params as $param => $value) { foreach ($params as $param => $value) {
$request->setAttribute( $request->setAttribute(
$param, qualifiedName: $param,
htmlspecialchars($value, ENT_XML1 | ENT_COMPAT, 'UTF-8') value: htmlspecialchars(
string: $value,
flags: ENT_XML1 | ENT_COMPAT,
encoding: 'UTF-8'
)
); );
} }
} }
@ -115,8 +117,11 @@ class Document
*/ */
protected function appendResponseDate(): void protected function appendResponseDate(): void
{ {
$responseDate = $this->dom->createElement('responseDate', gmdate('Y-m-d\TH:i:s\Z')); $this->createElement(
$this->rootNode->appendChild($responseDate); localName: 'responseDate',
value: gmdate(format: 'Y-m-d\TH:i:s\Z'),
appendToRoot: true
);
} }
/** /**
@ -126,20 +131,20 @@ class Document
*/ */
protected function appendRootElement(): void protected function appendRootElement(): void
{ {
$this->rootNode = $this->dom->createElement('OAI-PMH'); $this->rootNode = $this->dom->createElement(localName: 'OAI-PMH');
$this->rootNode->setAttribute( $this->rootNode->setAttribute(
'xmlns', qualifiedName: 'xmlns',
'http://www.openarchives.org/OAI/2.0/' value: 'http://www.openarchives.org/OAI/2.0/'
); );
$this->rootNode->setAttribute( $this->rootNode->setAttribute(
'xmlns:xsi', qualifiedName: 'xmlns:xsi',
'http://www.w3.org/2001/XMLSchema-instance' value: 'http://www.w3.org/2001/XMLSchema-instance'
); );
$this->rootNode->setAttribute( $this->rootNode->setAttribute(
'xsi:schemaLocation', qualifiedName: 'xsi:schemaLocation',
'http://www.openarchives.org/OAI/2.0/ https://www.openarchives.org/OAI/2.0/OAI-PMH.xsd' 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 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->dom->preserveWhiteSpace = false;
$this->addProcessingInstructions(); $this->addProcessingInstructions();
} }
@ -166,11 +171,15 @@ class Document
public function createElement(string $localName, string $value = '', bool $appendToRoot = false): DOMElement public function createElement(string $localName, string $value = '', bool $appendToRoot = false): DOMElement
{ {
$node = $this->dom->createElement( $node = $this->dom->createElement(
$localName, localName: $localName,
htmlspecialchars($value, ENT_XML1, 'UTF-8') value: htmlspecialchars(
string: $value,
flags: ENT_XML1,
encoding: 'UTF-8'
)
); );
if ($appendToRoot) { if ($appendToRoot) {
$this->rootNode->appendChild($node); $this->rootNode->appendChild(node: $node);
} }
return $node; return $node;
} }
@ -182,21 +191,21 @@ class Document
* *
* @return DOMNode The imported XML node * @return DOMNode The imported XML node
* *
* @throws DOMException * @throws DOMException if the data cannot be imported
*/ */
public function importData(string $data): DOMNode 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; $document->preserveWhiteSpace = false;
if ($document->loadXML($data) === true) { if ($document->loadXML(source: $data) === true) {
/** @var DOMElement */ /** @var DOMElement */
$rootNode = $document->documentElement; $rootNode = $document->documentElement;
$node = $this->dom->importNode($rootNode, true); $node = $this->dom->importNode(node: $rootNode, deep: true);
return $node; return $node;
} else { } else {
throw new DOMException( throw new DOMException(
'Could not import the XML data. Most likely it is not well-formed.', message: 'Could not import the XML data. Most likely it is not well-formed.',
500 code: 500
); );
} }
} }
@ -206,9 +215,8 @@ class Document
* *
* @param ServerRequestInterface $serverRequest The PSR-7 HTTP Server Request * @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->createDocument();
$this->appendRootElement(); $this->appendRootElement();
$this->appendResponseDate(); $this->appendResponseDate();

View File

@ -22,13 +22,7 @@ declare(strict_types=1);
namespace OCC\OaiPmh2; namespace OCC\OaiPmh2;
use Countable; use Doctrine\Common\Collections\ArrayCollection;
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 OCC\OaiPmh2\Entity\Token; use OCC\OaiPmh2\Entity\Token;
/** /**
@ -37,35 +31,15 @@ use OCC\OaiPmh2\Entity\Token;
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com> * @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package OAIPMH2 * @package OAIPMH2
* *
* @template QueryResult of array<string, Format|Record|Set> * @template TEntity of Entity
* @implements Iterator<QueryResult> * @extends ArrayCollection<string, TEntity>
*/ */
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. * This holds the optional resumption token.
*/ */
protected ?Token $resumptionToken = null; private ?Token $resumptionToken;
/**
* Get the query result.
*
* @return QueryResult The result set
*/
public function getQueryResult(): array
{
return $this->data;
}
/** /**
* Get the resumption token. * Get the resumption token.
@ -92,10 +66,12 @@ class Result implements Countable, Iterator
/** /**
* Create new result set. * Create new result set.
* *
* @param QueryResult $queryResult The Doctrine result set * @param array<string, TEntity> $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;
} }
} }

View File

@ -0,0 +1,114 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2024 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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 <sebastian.meyer@opencultureconsulting.com>
* @package OAIPMH2
*/
class ConfigurationValidator
{
/**
* Get constraints for configuration array.
*
* @return array<Constraint> 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<array-key, mixed> $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()
);
}
}

View File

@ -0,0 +1,72 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2024 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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 <sebastian.meyer@opencultureconsulting.com>
* @package OAIPMH2
*/
class RegExValidator
{
/**
* Get constraints for regular expression.
*
* @param string $regEx The regular expression for validation
*
* @return array<Constraint> 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)
);
}
}

View File

@ -0,0 +1,65 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2024 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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 <sebastian.meyer@opencultureconsulting.com>
* @package OAIPMH2
*/
class UrlValidator
{
/**
* Get constraints for URLs.
*
* @return array<Constraint> 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()
);
}
}

View File

@ -0,0 +1,79 @@
<?php
/**
* OAI-PMH 2.0 Data Provider
* Copyright (C) 2024 Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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 <sebastian.meyer@opencultureconsulting.com>
* @package OAIPMH2
*/
class XmlValidator
{
/**
* Get constraints for XML.
*
* @return array<Constraint> 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;
}
}