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="
http://pmd.sf.net/ruleset_xml_schema.xsd">
<description>
Open Culture Consulting follows PHP Mess Detector standards.
Open Culture Consulting follows PHP Mess Detector standards with few exceptions.
</description>
<rule ref="rulesets/cleancode.xml">
<!--
We sometimes want to use else expressions for better readability.
-->
<exclude name="ElseExpression" />
<!-- We need to statically access third-party helpers from Symfony. -->
<exclude name="StaticAccess" />
<exclude name="BooleanArgumentFlag" />
</rule>
<rule ref="rulesets/codesize.xml" />
<rule ref="rulesets/controversial.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">
<!-- We have to declare unused parameters to satisfy interface requirements. -->
<!--
We have to declare unused parameters to satisfy interface requirements.
-->
<exclude name="UnusedFormalParameter" />
</rule>
</ruleset>

View File

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

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 {
ConsoleRunner::run(
new SingleManagerProvider(
Database::getInstance()->getEntityManager()
EntityManager::getInstance()
),
$commands
);

View File

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

738
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -8,23 +8,40 @@
findUnusedBaselineEntry="true"
findUnusedCode="true"
findUnusedVariablesAndParams="true"
reportMixedIssues="false"
>
<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">
<file name="src/Console/CsvImportCommand.php"/>
<file name="src/Console/UpdateFormatsCommand.php"/>
</errorLevel>
</PossiblyNullArrayOffset>
</PossiblyNullReference>
<!--
DBAL entities require getter/setter methods even if they are never called directly.
-->
<PossiblyUnusedMethod errorLevel="suppress"/>
<PossiblyUnusedReturnValue errorLevel="suppress"/>
<!--
Some properties are not set in the constructor and hence checked for initialization.
-->
<PropertyNotSetInConstructor errorLevel="suppress"/>
<RedundantCastGivenDocblockType errorLevel="suppress"/>
<RedundantConditionGivenDocblockType errorLevel="suppress"/>
<RedundantFunctionCallGivenDocblockType 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>
<errorLevel type="suppress">
<referencedClass name="OCC\OaiPmh2\Middleware\GetRecord"/>

View File

@ -31,19 +31,19 @@ use OCC\PSR15\QueueRequestHandler;
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package OAIPMH2
*/
class App
final class App
{
/**
* The PSR-15 Server Request Handler.
*/
protected QueueRequestHandler $requestHandler;
private QueueRequestHandler $requestHandler;
/**
* Instantiate application.
*/
public function __construct()
{
$this->requestHandler = new QueueRequestHandler([new Dispatcher()]);
$this->requestHandler = new QueueRequestHandler(middlewares: [new Dispatcher()]);
}
/**
@ -54,6 +54,9 @@ class App
public function run(): void
{
$this->requestHandler->handle();
if ($this->requestHandler->response->hasHeader('Warning')) {
// An exception occured. Maybe we don't want to output the response, but log an error instead?
}
$this->requestHandler->respond();
}
}

View File

@ -23,11 +23,10 @@ declare(strict_types=1);
namespace OCC\OaiPmh2;
use OCC\Basics\Traits\Singleton;
use OCC\OaiPmh2\Validator\ConfigurationValidator;
use Symfony\Component\Filesystem\Exception\FileNotFoundException;
use Symfony\Component\Filesystem\Path;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Exception\ValidationFailedException;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Yaml\Yaml;
/**
@ -36,18 +35,18 @@ use Symfony\Component\Yaml\Yaml;
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package OAIPMH2
*
* @property-read string $repositoryName
* @property-read string $adminEmail
* @property-read string $database
* @property-read array $metadataPrefix
* @property-read string $deletedRecords
* @property-read int $maxRecords
* @property-read int $tokenValid
* @property-read string $repositoryName Common name of this repository
* @property-read string $adminEmail Repository contact's e-mail address
* @property-read string $database Database's data source name (DSN)
* @property-read array $metadataPrefix Array of served metadata prefixes
* @property-read string $deletedRecords Repository's deleted records policy
* @property-read int $maxRecords Maximum number of records served per request
* @property-read int $tokenValid Number of seconds resumption tokens are valid
*
* @template TKey of string
* @template TValue of array|int|string
*/
class Configuration
final class Configuration
{
use Singleton;
@ -65,98 +64,6 @@ class Configuration
*/
protected readonly array $settings;
/**
* Get constraints for configuration array.
*
* @return Assert\Collection The collection of constraints
*/
protected function getValidationConstraints(): Assert\Collection
{
return new Assert\Collection([
'repositoryName' => [
new Assert\Type('string'),
new Assert\NotBlank()
],
'adminEmail' => [
new Assert\Type('string'),
new Assert\Email(['mode' => 'html5']),
new Assert\NotBlank()
],
'database' => [
new Assert\Type('string'),
new Assert\NotBlank()
],
'metadataPrefix' => [
new Assert\Type('array'),
new Assert\All([
new Assert\Collection([
'schema' => [
new Assert\Type('string'),
new Assert\Url(),
new Assert\NotBlank()
],
'namespace' => [
new Assert\Type('string'),
new Assert\Url(),
new Assert\NotBlank()
]
])
])
],
'deletedRecords' => [
new Assert\Type('string'),
new Assert\Choice(['no', 'persistent', 'transient']),
new Assert\NotBlank()
],
'maxRecords' => [
new Assert\Type('int'),
new Assert\Range([
'min' => 1,
'max' => 100
])
],
'tokenValid' => [
new Assert\Type('int'),
new Assert\Range([
'min' => 300,
'max' => 86400
])
]
]);
}
/**
* Read and validate configuration file.
*
* @return array<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.
*
@ -165,11 +72,24 @@ class Configuration
*/
private function __construct()
{
try {
$this->settings = $this->loadConfigFile();
} catch (FileNotFoundException | ValidationFailedException $exception) {
throw $exception;
$configPath = Path::canonicalize(path: self::CONFIG_FILE);
if (!is_readable(filename: $configPath)) {
throw new FileNotFoundException(
message: 'Configuration file not found or not readable.',
code: 500,
path: $configPath
);
}
/** @var array<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
*
* @return TValue|null The setting or NULL
* @return ?TValue The setting or NULL
*/
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>
* @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
{
/**
* This holds the command's arguments and options.
*
* @var CliArguments
*/
protected array $arguments;
/**
* This holds the entity manager singleton.
*/
protected EntityManager $em;
/**
* This holds the PHP memory limit in bytes.
*/
protected int $memoryLimit;
/**
* Flushes changes to the database if memory limit reaches 50%.
*
* @return void
*/
protected function checkMemoryUsage(): void
{
if ((memory_get_usage() / $this->getPhpMemoryLimit()) > 0.5) {
$this->em->flush();
}
}
/**
* Clears the result cache.
*
@ -47,11 +91,13 @@ abstract class Console extends Command
/** @var Application */
$app = $this->getApplication();
$app->doRun(
new ArrayInput([
'command' => 'orm:clear-cache:result',
'--flush' => true
]),
new NullOutput()
input: new ArrayInput(
parameters: [
'command' => 'orm:clear-cache:result',
'--flush' => true
]
),
output: new NullOutput()
);
}
@ -62,23 +108,26 @@ abstract class Console extends Command
*/
protected function getPhpMemoryLimit(): int
{
$ini = trim(ini_get('memory_limit'));
$limit = (int) $ini;
if ($limit < 0) {
return -1;
if (!isset($this->memoryLimit)) {
$ini = trim(string: ini_get(option: 'memory_limit'));
$limit = (int) $ini;
if ($limit < 0) {
return -1;
}
$unit = strtolower($ini[strlen($ini) - 1]);
switch ($unit) {
case 'g':
$limit *= 1024;
// no break
case 'm':
$limit *= 1024;
// no break
case 'k':
$limit *= 1024;
}
$this->memoryLimit = $limit;
}
$unit = strtolower($ini[strlen($ini) - 1]);
switch ($unit) {
case 'g':
$limit *= 1024;
// no break
case 'm':
$limit *= 1024;
// no break
case 'k':
$limit *= 1024;
}
return $limit;
return $this->memoryLimit;
}
/**
@ -91,32 +140,68 @@ abstract class Console extends Command
*/
protected function validateInput(InputInterface $input, OutputInterface $output): bool
{
/** @var array<string, string> */
$arguments = $input->getArguments();
/** @var CliArguments */
$mergedArguments = array_merge($input->getArguments(), $input->getOptions());
$this->arguments = $mergedArguments;
$formats = Database::getInstance()->getMetadataFormats()->getQueryResult();
if (!array_key_exists($arguments['format'], $formats)) {
$output->writeln([
'',
sprintf(
' [ERROR] Metadata format "%s" is not supported. ',
$arguments['format']
),
''
]);
if (array_key_exists('format', $this->arguments)) {
$formats = $this->em->getMetadataFormats();
if (!$formats->containsKey(key: $this->arguments['format'])) {
$output->writeln(
messages: [
'',
sprintf(
format: ' [ERROR] Metadata format "%s" is not supported. ',
values: $this->arguments['format']
),
''
]
);
return false;
}
}
if (array_key_exists('file', $this->arguments) && !is_readable(filename: $this->arguments['file'])) {
$output->writeln(
messages: [
'',
sprintf(
format: ' [ERROR] File "%s" not found or not readable. ',
values: $this->arguments['file']
),
''
]
);
return false;
}
if (!is_readable($arguments['file'])) {
$output->writeln([
'',
sprintf(
' [ERROR] File "%s" not found or not readable. ',
$arguments['file']
),
''
]);
return false;
if (array_key_exists('sets', $this->arguments)) {
$sets = $this->em->getSets();
$invalidSets = array_diff($this->arguments['sets'], $sets->getKeys());
if (count($invalidSets) !== 0) {
$output->writeln(
messages: [
'',
sprintf(
format: ' [ERROR] Sets "%s" are not supported. ',
values: implode('", "', $invalidSets)
),
''
]
);
return false;
}
}
return true;
}
/**
* Create new console command instance.
*
* @param ?string $name The name of the command
* passing null means it must be set in configure()
*/
public function __construct(?string $name = null)
{
$this->em = EntityManager::getInstance();
parent::__construct($name);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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;
use DOMElement;
use GuzzleHttp\Psr7\Utils;
use OCC\OaiPmh2\Entity\Token;
use OCC\OaiPmh2\Middleware\ErrorHandler;
use OCC\PSR15\AbstractMiddleware;
use Psr\Http\Message\ResponseInterface;
@ -33,13 +35,99 @@ use Psr\Http\Message\ServerRequestInterface;
*
* @author Sebastian Meyer <sebastian.meyer@opencultureconsulting.com>
* @package OAIPMH2
*
* @psalm-type OaiRequestMetadata = array{
* verb: string,
* identifier: ?string,
* metadataPrefix: ?string,
* from: ?string,
* until: ?string,
* set: ?string,
* resumptionToken: ?string,
* counter: int,
* completeListSize: int
* }
*/
abstract class Middleware extends AbstractMiddleware
{
/**
* This holds the request metadata.
*
* @var OaiRequestMetadata
*/
protected array $arguments = [
'verb' => '',
'identifier' => null,
'metadataPrefix' => null,
'from' => null,
'until' => null,
'set' => null,
'resumptionToken' => null,
'counter' => 0,
'completeListSize' => 0
];
/**
* This holds the entity manager singleton.
*/
protected EntityManager $em;
/**
* This holds the prepared response document.
*/
protected Document $preparedResponse;
protected Response $preparedResponse;
/**
* Add resumption token information to response document.
*
* @param DOMElement $node The DOM node to add the resumption token to
* @param ?Token $token The new resumption token or NULL if none
*
* @return void
*/
protected function addResumptionToken(DOMElement $node, ?Token $token): void
{
if (isset($token) || isset($this->arguments['resumptionToken'])) {
$resumptionToken = $this->preparedResponse->createElement(localName: 'resumptionToken');
if (isset($token)) {
$resumptionToken->nodeValue = $token->getToken();
$resumptionToken->setAttribute(
qualifiedName: 'expirationDate',
value: $token->getValidUntil()->format(format: 'Y-m-d\TH:i:s\Z')
);
$this->arguments['completeListSize'] = $token->getParameters()['completeListSize'];
}
$resumptionToken->setAttribute(
qualifiedName: 'completeListSize',
value: (string) $this->arguments['completeListSize']
);
$resumptionToken->setAttribute(
qualifiedName: 'cursor',
value: (string) ($this->arguments['counter'] * Configuration::getInstance()->maxRecords)
);
$node->appendChild(node: $resumptionToken);
}
}
/**
* Check for resumption token and populate request arguments.
*
* @return void
*/
protected function checkResumptionToken(): void
{
if (isset($this->arguments['resumptionToken'])) {
$token = $this->em->getResumptionToken(
token: $this->arguments['resumptionToken'],
verb: $this->arguments['verb']
);
if (isset($token)) {
$this->arguments = array_merge($this->arguments, $token->getParameters());
} else {
ErrorHandler::getInstance()->withError(errorCode: 'badResumptionToken');
}
}
}
/**
* Prepare response document.
@ -59,7 +147,10 @@ abstract class Middleware extends AbstractMiddleware
*/
protected function processRequest(ServerRequestInterface $request): ServerRequestInterface
{
$this->prepareResponse($request);
/** @var OaiRequestMetadata */
$arguments = $request->getAttributes();
$this->arguments = array_merge($this->arguments, $arguments);
$this->prepareResponse(request: $request);
return $request;
}
@ -73,18 +164,22 @@ abstract class Middleware extends AbstractMiddleware
protected function processResponse(ResponseInterface $response): ResponseInterface
{
if (!ErrorHandler::getInstance()->hasErrors() && isset($this->preparedResponse)) {
$response = $response->withBody(Utils::streamFor((string) $this->preparedResponse));
$response = $response->withBody(
body: Utils::streamFor(
resource: (string) $this->preparedResponse
)
);
}
return $response;
}
/**
* The constructor must have the same signature for all derived classes, thus make it final.
*
* @see https://psalm.dev/229
*/
final public function __construct()
{
// Make constructor final to avoid issues in dispatcher.
// @see https://psalm.dev/229
$this->em = EntityManager::getInstance();
}
}

View File

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace OCC\OaiPmh2\Middleware;
use OCC\OaiPmh2\EntityManager;
use OCC\OaiPmh2\Middleware;
use OCC\PSR15\AbstractMiddleware;
use Psr\Http\Message\ResponseInterface;
@ -37,6 +38,8 @@ class Dispatcher extends AbstractMiddleware
{
/**
* List of defined OAI-PMH parameters.
*
* @var string[]
*/
protected const OAI_PARAMS = [
'verb',
@ -62,14 +65,14 @@ class Dispatcher extends AbstractMiddleware
/** @var array<string, string> */
$arguments = $request->getQueryParams();
} elseif ($request->getMethod() === 'POST') {
if ($request->getHeaderLine('Content-Type') === 'application/x-www-form-urlencoded') {
if ($request->getHeaderLine(name: 'Content-Type') === 'application/x-www-form-urlencoded') {
/** @var array<string, string> */
$arguments = (array) $request->getParsedBody();
}
}
if ($this->validateArguments($arguments)) {
if ($this->validateArguments(arguments: $arguments)) {
foreach ($arguments as $param => $value) {
$request = $request->withAttribute($param, $value);
$request = $request->withAttribute(name: $param, value: $value);
}
}
return $request;
@ -84,17 +87,17 @@ class Dispatcher extends AbstractMiddleware
*/
protected function processRequest(ServerRequestInterface $request): ServerRequestInterface
{
$request = $this->getRequestWithAttributes($request);
$request = $this->getRequestWithAttributes(request: $request);
$errorHandler = ErrorHandler::getInstance();
if (!$errorHandler->hasErrors()) {
/** @var string */
$verb = $request->getAttribute('verb');
$verb = $request->getAttribute(name: 'verb');
$middleware = __NAMESPACE__ . '\\' . $verb;
if (is_a($middleware, Middleware::class, true)) {
$this->requestHandler->queue->enqueue(new $middleware());
if (is_a(object_or_class: $middleware, class: Middleware::class, allow_string: true)) {
$this->requestHandler->queue->enqueue(value: new $middleware());
}
}
$this->requestHandler->queue->enqueue($errorHandler);
$this->requestHandler->queue->enqueue(value: $errorHandler);
return $request;
}
@ -110,7 +113,7 @@ class Dispatcher extends AbstractMiddleware
// TODO: Add support for content compression
// https://openarchives.org/OAI/openarchivesprotocol.html#ResponseCompression
// https://github.com/middlewares/encoder
return $response->withHeader('Content-Type', 'text/xml');
return $response->withHeader(name: 'Content-Type', value: 'text/xml');
}
/**
@ -124,62 +127,166 @@ class Dispatcher extends AbstractMiddleware
*/
protected function validateArguments(array $arguments): bool
{
$errorHandler = ErrorHandler::getInstance();
if (
count(array_diff(array_keys($arguments), self::OAI_PARAMS)) !== 0
or !isset($arguments['verb'])
) {
$errorHandler->withError('badArgument');
ErrorHandler::getInstance()->withError(errorCode: 'badArgument');
} else {
switch ($arguments['verb']) {
case 'GetRecord':
if (
count($arguments) !== 3
or !isset($arguments['identifier'])
or !isset($arguments['metadataPrefix'])
) {
$errorHandler->withError('badArgument');
}
break;
case 'Identify':
if (count($arguments) !== 1) {
$errorHandler->withError('badArgument');
}
break;
case 'ListIdentifiers':
case 'ListRecords':
if (
isset($arguments['metadataPrefix'])
xor isset($arguments['resumptionToken'])
) {
if (
(isset($arguments['resumptionToken']) && count($arguments) !== 2)
or isset($arguments['identifier'])
) {
$errorHandler->withError('badArgument');
}
} else {
$errorHandler->withError('badArgument');
}
break;
case 'ListMetadataFormats':
if (count($arguments) !== 1) {
if (!isset($arguments['identifier']) || count($arguments) !== 2) {
$errorHandler->withError('badArgument');
}
}
break;
case 'ListSets':
if (count($arguments) !== 1) {
if (!isset($arguments['resumptionToken']) || count($arguments) !== 2) {
$errorHandler->withError('badArgument');
}
}
break;
default:
$errorHandler->withError('badVerb');
match ($arguments['verb']) {
'GetRecord' => $this->validateGetRecord(arguments: $arguments),
'Identify' => $this->validateIdentify(arguments: $arguments),
'ListIdentifiers', 'ListRecords' => $this->validateListRecords(arguments: $arguments),
'ListMetadataFormats' => $this->validateListFormats(arguments: $arguments),
'ListSets' => $this->validateListSets(arguments: $arguments),
default => ErrorHandler::getInstance()->withError(errorCode: 'badVerb')
};
if (!ErrorHandler::getInstance()->hasErrors()) {
$this->validateMetadataPrefix(prefix: $arguments['metadataPrefix'] ?? null);
$this->validateDateTime(datetime: $arguments['from'] ?? null);
$this->validateDateTime(datetime: $arguments['until'] ?? null);
$this->validateSet($arguments['set'] ?? null);
}
}
return !ErrorHandler::getInstance()->hasErrors();
}
/**
* Validate "from" and "until" argument.
*
* @param ?string $datetime The datetime string to validate or NULL if none
*
* @return void
*/
protected function validateDateTime(?string $datetime): void
{
if (isset($datetime)) {
$date = date_parse(datetime: $datetime);
if ($date['warning_count'] > 0 || $date['error_count'] > 0) {
ErrorHandler::getInstance()->withError(errorCode: 'badArgument');
}
}
}
/**
* Validate request arguments for verb GetRecord.
*
* @param string[] $arguments The request parameters
*
* @return void
*/
protected function validateGetRecord(array $arguments): void
{
if (
count($arguments) !== 3
or !isset($arguments['identifier'])
or !isset($arguments['metadataPrefix'])
) {
ErrorHandler::getInstance()->withError(errorCode: 'badArgument');
}
}
/**
* Validate request arguments for verb Identify.
*
* @param string[] $arguments The request parameters
*
* @return void
*/
protected function validateIdentify(array $arguments): void
{
if (count($arguments) !== 1) {
ErrorHandler::getInstance()->withError(errorCode: 'badArgument');
}
}
/**
* Validate request arguments for verb ListMetadataFormats.
*
* @param string[] $arguments The request parameters
*
* @return void
*/
protected function validateListFormats(array $arguments): void
{
if (count($arguments) !== 1) {
if (!isset($arguments['identifier']) || count($arguments) !== 2) {
ErrorHandler::getInstance()->withError(errorCode: 'badArgument');
}
}
}
/**
* Validate request arguments for verbs ListIdentifiers and ListRecords.
*
* @param string[] $arguments The request parameters
*
* @return void
*/
protected function validateListRecords(array $arguments): void
{
if (
isset($arguments['metadataPrefix'])
xor isset($arguments['resumptionToken'])
) {
if (
(isset($arguments['resumptionToken']) && count($arguments) !== 2)
or isset($arguments['identifier'])
) {
ErrorHandler::getInstance()->withError(errorCode: 'badArgument');
}
} else {
ErrorHandler::getInstance()->withError(errorCode: 'badArgument');
}
}
/**
* Validate request arguments for verb ListSets.
*
* @param string[] $arguments The request parameters
*
* @return void
*/
protected function validateListSets(array $arguments): void
{
if (count($arguments) !== 1) {
if (!isset($arguments['resumptionToken']) || count($arguments) !== 2) {
ErrorHandler::getInstance()->withError(errorCode: 'badArgument');
}
}
}
/**
* Validate "metadataPrefix" argument.
*
* @param ?string $prefix The metadata prefix
*
* @return void
*/
protected function validateMetadataPrefix(?string $prefix): void
{
if (isset($prefix)) {
$formats = EntityManager::getInstance()->getMetadataFormats();
if (!$formats->containsKey(key: $prefix)) {
ErrorHandler::getInstance()->withError(errorCode: 'cannotDisseminateFormat');
}
}
}
/**
* Validate "set" argument.
*
* @param ?string $spec The set spec
*
* @return void
*/
protected function validateSet(?string $spec): void
{
if (isset($spec)) {
$sets = EntityManager::getInstance()->getSets();
if (!$sets->containsKey(key: $spec)) {
ErrorHandler::getInstance()->withError(errorCode: 'badArgument');
}
}
return !$errorHandler->hasErrors();
}
}

View File

@ -25,7 +25,7 @@ namespace OCC\OaiPmh2\Middleware;
use DomainException;
use GuzzleHttp\Psr7\Utils;
use OCC\Basics\Traits\Singleton;
use OCC\OaiPmh2\Document;
use OCC\OaiPmh2\Response;
use OCC\PSR15\AbstractMiddleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
@ -48,7 +48,7 @@ class ErrorHandler extends AbstractMiddleware
protected const OAI_ERRORS = [
'badArgument' => 'The request includes illegal arguments, is missing required arguments, includes a repeated argument, or values for arguments have an illegal syntax.',
'badResumptionToken' => 'The value of the resumptionToken argument is invalid or expired.',
'badVerb' => 'Value of the verb argument is not a legal OAI-PMH verb, the verb argument is missing, or the verb argument is repeated.',
'badVerb' => 'The value of the verb argument is not a legal OAI-PMH verb, the verb argument is missing, or the verb argument is repeated.',
'cannotDisseminateFormat' => 'The metadata format identified by the value given for the metadataPrefix argument is not supported by the item or by the repository.',
'idDoesNotExist' => 'The value of the identifier argument is unknown or illegal in this repository.',
'noRecordsMatch' => 'The combination of the values of the from, until, set and metadataPrefix arguments results in an empty list.',
@ -70,12 +70,19 @@ class ErrorHandler extends AbstractMiddleware
*/
protected function getResponseBody(): StreamInterface
{
$document = new Document($this->requestHandler->request);
$response = new Response(serverRequest: $this->requestHandler->request);
foreach (array_unique($this->errors) as $errorCode) {
$error = $document->createElement('error', self::OAI_ERRORS[$errorCode], true);
$error->setAttribute('code', $errorCode);
$error = $response->createElement(
localName: 'error',
value: self::OAI_ERRORS[$errorCode],
appendToRoot: true
);
$error->setAttribute(
qualifiedName: 'code',
value: $errorCode
);
}
return Utils::streamFor((string) $document);
return Utils::streamFor(resource: (string) $response);
}
/**
@ -98,7 +105,7 @@ class ErrorHandler extends AbstractMiddleware
protected function processResponse(ResponseInterface $response): ResponseInterface
{
if ($this->hasErrors()) {
$response = $response->withBody($this->getResponseBody());
$response = $response->withBody(body: $this->getResponseBody());
}
return $response;
}
@ -118,11 +125,11 @@ class ErrorHandler extends AbstractMiddleware
$this->errors[] = $errorCode;
} else {
throw new DomainException(
sprintf(
'Valid OAI-PMH error code expected, "%s" given.',
$errorCode
message: sprintf(
format: 'Valid OAI-PMH error code expected, "%s" given.',
values: $errorCode
),
500
code: 500
);
}
return $this;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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