diff --git a/src/Configuration.php b/src/Configuration.php index 59c7854..ca62df2 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -152,7 +152,7 @@ class Configuration if ($violations->count() > 0) { throw new ValidationFailedException(null, $violations); } - /** @var array $config */ + /** @var array */ return $config; } diff --git a/src/Console/AddRecordCommand.php b/src/Console/AddRecordCommand.php index 807ce70..8f54d55 100644 --- a/src/Console/AddRecordCommand.php +++ b/src/Console/AddRecordCommand.php @@ -22,6 +22,7 @@ declare(strict_types=1); namespace OCC\OaiPmh2\Console; +use OCC\OaiPmh2\ConsoleCommand; use OCC\OaiPmh2\Database; use OCC\OaiPmh2\Database\Format; use OCC\OaiPmh2\Database\Record; @@ -41,7 +42,7 @@ use Symfony\Component\Console\Output\OutputInterface; name: 'oai:records:add', description: 'Add or update a record in the database' )] -class AddRecordCommand extends Command +class AddRecordCommand extends ConsoleCommand { /** * Configures the current command. @@ -110,6 +111,8 @@ class AddRecordCommand extends Command Database::getInstance()->addOrUpdateRecord($record); Database::getInstance()->pruneOrphanSets(); + $this->clearResultCache(); + $output->writeln([ '', sprintf( diff --git a/src/Console/CsvImportCommand.php b/src/Console/CsvImportCommand.php index 86c7dc1..a17ec6f 100644 --- a/src/Console/CsvImportCommand.php +++ b/src/Console/CsvImportCommand.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace OCC\OaiPmh2\Console; use DateTime; +use OCC\OaiPmh2\ConsoleCommand; use OCC\OaiPmh2\Database; use OCC\OaiPmh2\Database\Format; use OCC\OaiPmh2\Database\Record; @@ -44,7 +45,7 @@ use Symfony\Component\Console\Output\OutputInterface; name: 'oai:records:import:csv', description: 'Import records from a CSV file' )] -class CsvImportCommand extends Command +class CsvImportCommand extends ConsoleCommand { /** * Configures the current command. @@ -99,8 +100,7 @@ class CsvImportCommand extends Command 'noValidation', null, InputOption::VALUE_NONE, - 'Omit content validation (improves performance for large record sets).', - false + 'Omit content validation (improves performance for large record sets).' ); parent::configure(); } @@ -135,7 +135,7 @@ class CsvImportCommand extends Command } $count = 0; - $progressIndicator = new ProgressIndicator($output, 'verbose', 200, ['⠏', '⠛', '⠹', '⢸', '⣰', '⣤', '⣆', '⡇']); + $progressIndicator = new ProgressIndicator($output, null, 100, ['⠏', '⠛', '⠹', '⢸', '⣰', '⣤', '⣆', '⡇']); $progressIndicator->start('Importing...'); while ($row = fgetcsv($file)) { @@ -153,10 +153,10 @@ class CsvImportCommand extends Command ++$count; $progressIndicator->advance(); - $progressIndicator->setMessage((string) $count . ' done.'); + $progressIndicator->setMessage('Importing... ' . (string) $count . ' records done.'); - // Flush to database if memory usage reaches 90% of available limit. - if ((memory_get_usage() / $memoryLimit) > 0.9) { + // Flush to database if memory usage reaches 30% of available limit. + if ((memory_get_usage() / $memoryLimit) > 0.3) { Database::getInstance()->flush([Record::class]); } } @@ -167,6 +167,8 @@ class CsvImportCommand extends Command fclose($file); + $this->clearResultCache(); + $output->writeln([ '', sprintf( @@ -229,30 +231,6 @@ class CsvImportCommand extends Command return $columns; } - /** - * Get the PHP memory limit in bytes. - * - * @return int The memory limit in bytes or -1 if unlimited - */ - protected function getMemoryLimit(): int - { - $ini = trim(ini_get('memory_limit')); - $limit = (int) $ini; - $unit = strtolower($ini[strlen($ini)-1]); - switch($unit) { - case 'g': - $limit *= 1024; - case 'm': - $limit *= 1024; - case 'k': - $limit *= 1024; - } - if ($limit < 0) { - return -1; - } - return $limit; - } - /** * Validate input arguments. * diff --git a/src/Console/DeleteRecordCommand.php b/src/Console/DeleteRecordCommand.php index c0b5e76..a196699 100644 --- a/src/Console/DeleteRecordCommand.php +++ b/src/Console/DeleteRecordCommand.php @@ -22,7 +22,7 @@ declare(strict_types=1); namespace OCC\OaiPmh2\Console; -use OCC\OaiPmh2\Configuration; +use OCC\OaiPmh2\ConsoleCommand; use OCC\OaiPmh2\Database; use OCC\OaiPmh2\Database\Format; use OCC\OaiPmh2\Database\Record; @@ -42,7 +42,7 @@ use Symfony\Component\Console\Output\OutputInterface; name: 'oai:records:delete', description: 'Delete a record from database' )] -class DeleteRecordCommand extends Command +class DeleteRecordCommand extends ConsoleCommand { /** * Configures the current command. @@ -89,6 +89,7 @@ class DeleteRecordCommand extends Command if (isset($record)) { Database::getInstance()->deleteRecord($record); + $this->clearResultCache(); $output->writeln([ '', sprintf( diff --git a/src/Console/PruneDeletedRecordsCommand.php b/src/Console/PruneDeletedRecordsCommand.php index dc575ab..c172712 100644 --- a/src/Console/PruneDeletedRecordsCommand.php +++ b/src/Console/PruneDeletedRecordsCommand.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace OCC\OaiPmh2\Console; use OCC\OaiPmh2\Configuration; +use OCC\OaiPmh2\ConsoleCommand; use OCC\OaiPmh2\Database; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -40,7 +41,7 @@ use Symfony\Component\Console\Output\OutputInterface; name: 'oai:records:prune', description: 'Prune deleted records from database' )] -class PruneDeletedRecordsCommand extends Command +class PruneDeletedRecordsCommand extends ConsoleCommand { /** * Configures the current command. @@ -75,6 +76,7 @@ class PruneDeletedRecordsCommand extends Command or ($policy === 'transient' && $forced) ) { $deleted = Database::getInstance()->pruneDeletedRecords(); + $this->clearResultCache(); $output->writeln([ '', sprintf( diff --git a/src/Console/PruneResumptionTokensCommand.php b/src/Console/PruneResumptionTokensCommand.php index 12cbb7b..51d3fa4 100644 --- a/src/Console/PruneResumptionTokensCommand.php +++ b/src/Console/PruneResumptionTokensCommand.php @@ -22,6 +22,7 @@ declare(strict_types=1); namespace OCC\OaiPmh2\Console; +use OCC\OaiPmh2\ConsoleCommand; use OCC\OaiPmh2\Database; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -38,7 +39,7 @@ use Symfony\Component\Console\Output\OutputInterface; name: 'oai:tokens:prune', description: 'Prune expired resumption tokens from database' )] -class PruneResumptionTokensCommand extends Command +class PruneResumptionTokensCommand extends ConsoleCommand { /** * Executes the current command. diff --git a/src/Console/UpdateFormatsCommand.php b/src/Console/UpdateFormatsCommand.php index ac35337..f27a56a 100644 --- a/src/Console/UpdateFormatsCommand.php +++ b/src/Console/UpdateFormatsCommand.php @@ -23,14 +23,12 @@ declare(strict_types=1); namespace OCC\OaiPmh2\Console; use OCC\OaiPmh2\Configuration; +use OCC\OaiPmh2\ConsoleCommand; use OCC\OaiPmh2\Database; use OCC\OaiPmh2\Database\Format; -use Symfony\Component\Console\Application; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Validator\Exception\ValidationFailedException; @@ -44,7 +42,7 @@ use Symfony\Component\Validator\Exception\ValidationFailedException; name: 'oai:formats:update', description: 'Update metadata formats in database from configuration' )] -class UpdateFormatsCommand extends Command +class UpdateFormatsCommand extends ConsoleCommand { /** * Executes the current command. @@ -103,15 +101,7 @@ class UpdateFormatsCommand extends Command ]); } } - /** @var Application */ - $app = $this->getApplication(); - $app->doRun( - new ArrayInput([ - 'command' => 'orm:clear-cache:result', - '--flush' => true - ]), - new NullOutput() - ); + $this->clearResultCache(); $currentFormats = array_keys(Database::getInstance()->getMetadataFormats()->getQueryResult()); if (count($currentFormats) > 0) { $output->writeln( diff --git a/src/ConsoleCommand.php b/src/ConsoleCommand.php new file mode 100644 index 0000000..89f0e72 --- /dev/null +++ b/src/ConsoleCommand.php @@ -0,0 +1,79 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +declare(strict_types=1); + +namespace OCC\OaiPmh2; + +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\NullOutput; + +/** + * Base class for all OAI-PMH console commands. + * + * @author Sebastian Meyer + * @package opencultureconsulting/oai-pmh2 + */ +abstract class ConsoleCommand extends Command +{ + /** + * Clears the result cache. + * + * @return void + */ + protected function clearResultCache(): void + { + /** @var Application */ + $app = $this->getApplication(); + $app->doRun( + new ArrayInput([ + 'command' => 'orm:clear-cache:result', + '--flush' => true + ]), + new NullOutput() + ); + } + + /** + * Gets the PHP memory limit in bytes. + * + * @return int The memory limit in bytes or -1 if unlimited + */ + protected function getMemoryLimit(): int + { + $ini = trim(ini_get('memory_limit')); + $limit = (int) $ini; + $unit = strtolower($ini[strlen($ini)-1]); + switch($unit) { + case 'g': + $limit *= 1024; + case 'm': + $limit *= 1024; + case 'k': + $limit *= 1024; + } + if ($limit < 0) { + return -1; + } + return $limit; + } +} diff --git a/src/Database.php b/src/Database.php index eb74445..58623cd 100644 --- a/src/Database.php +++ b/src/Database.php @@ -41,7 +41,6 @@ use OCC\OaiPmh2\Database\Set; use OCC\OaiPmh2\Database\Token; use Symfony\Component\Cache\Adapter\PhpFilesAdapter; use Symfony\Component\Filesystem\Path; -use Symfony\Component\Validator\Exception\ValidationFailedException; /** * Handles all database shenanigans. @@ -124,6 +123,51 @@ class Database } } + /** + * 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. * @@ -319,7 +363,7 @@ class Database * * @return Result The sets and possibly a resumption token */ - public function getSets($counter = 0): Result + public function getSets(int $counter = 0): Result { $result = []; $maxRecords = Configuration::getInstance()->maxRecords; @@ -388,18 +432,21 @@ class Database /** * Prune orphan sets. * - * @return void + * @return int The number of removed sets */ - public function pruneOrphanSets(): void + 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; } /** @@ -419,45 +466,6 @@ class Database return count($tokens); } - /** - * Delete metadata format and all associated records. - * - * @param Format $format The metadata format - * - * @return void - */ - public function deleteMetadataFormat(Format $format): void - { - $repository = $this->entityManager->getRepository(Record::class); - $criteria = Criteria::create()->where(Criteria::expr()->eq('format', $format)); - $records = $repository->matching($criteria); - foreach ($records as $record) { - $this->entityManager->remove($record); - } - $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(); - } - /** * This is a singleton class, thus the constructor is private. * @@ -480,7 +488,7 @@ class Database new AttributeDriver([__DIR__ . '/Database']) ); $configuration->setProxyDir(__DIR__ . '/../var/generated'); - $configuration->setProxyNamespace('OCC\OaiPmh2\Proxy'); + $configuration->setProxyNamespace('OCC\OaiPmh2\Database\Proxy'); $configuration->setQueryCache( new PhpFilesAdapter( 'Query', diff --git a/src/Document.php b/src/Document.php index 7278d8a..db042cf 100644 --- a/src/Document.php +++ b/src/Document.php @@ -28,6 +28,7 @@ use DOMException; use DOMNode; use GuzzleHttp\Psr7\Uri; use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\UriInterface; /** * An OAI-PMH XML response object. @@ -47,6 +48,111 @@ class Document */ protected DOMElement $rootNode; + /** + * This holds the current server request. + */ + protected ServerRequestInterface $serverRequest; + + /** + * Add XSL processing instructions to XML response document. + * + * @return void + */ + protected function addProcessingInstructions(): void + { + $uri = $this->serverRequest->getUri(); + $basePath = $uri->getPath(); + if (str_ends_with($basePath, 'index.php')) { + $basePath = pathinfo($basePath, PATHINFO_DIRNAME); + } + $stylesheet = Uri::composeComponents( + $uri->getScheme(), + $uri->getAuthority(), + rtrim($basePath, '/') . '/resources/stylesheet.xsl', + null, + null + ); + $xslt = $this->dom->createProcessingInstruction( + 'xml-stylesheet', + sprintf( + 'type="text/xsl" href="%s"', + $stylesheet + ) + ); + $this->dom->appendChild($xslt); + } + + /** + * Create and append request element. + * + * @return void + */ + protected function appendRequest(): void + { + $uri = $this->serverRequest->getUri(); + $baseUrl = Uri::composeComponents( + $uri->getScheme(), + $uri->getAuthority(), + $uri->getPath(), + null, + null + ); + $request = $this->dom->createElement('request', $baseUrl); + $this->rootNode->appendChild($request); + foreach ($this->serverRequest->getAttributes() as $param => $value) { + $request->setAttribute( + $param, + htmlspecialchars($value, ENT_XML1 | ENT_COMPAT, 'UTF-8') + ); + } + } + + /** + * Create and append response date element. + * + * @return void + */ + protected function appendResponseDate(): void + { + $responseDate = $this->dom->createElement('responseDate', gmdate('Y-m-d\TH:i:s\Z')); + $this->rootNode->appendChild($responseDate); + } + + /** + * Create and append root element. + * + * @return void + */ + protected function appendRootElement(): void + { + $this->rootNode = $this->dom->createElement('OAI-PMH'); + $this->rootNode->setAttribute( + 'xmlns', + 'http://www.openarchives.org/OAI/2.0/' + ); + $this->rootNode->setAttribute( + 'xmlns:xsi', + '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' + ); + $this->dom->appendChild($this->rootNode); + } + + /** + * Create the DOM document. + * + * @return void + */ + protected function createDocument(): void + { + $this->dom = new DOMDocument('1.0', 'UTF-8'); + $this->dom->preserveWhiteSpace = false; + $this->addProcessingInstructions(); + } + /** * Create a new XML element. * @@ -101,71 +207,11 @@ class Document */ public function __construct(ServerRequestInterface $serverRequest) { - $uri = $serverRequest->getUri(); - - // Create XML document. - $this->dom = new DOMDocument('1.0', 'UTF-8'); - $this->dom->preserveWhiteSpace = false; - - // Add processing instructions. - $basePath = $uri->getPath(); - if (str_ends_with($basePath, 'index.php')) { - $basePath = pathinfo($basePath, PATHINFO_DIRNAME); - } - $stylesheet = Uri::composeComponents( - $uri->getScheme(), - $uri->getAuthority(), - rtrim($basePath, '/') . '/resources/stylesheet.xsl', - null, - null - ); - $xslt = $this->dom->createProcessingInstruction( - 'xml-stylesheet', - sprintf( - 'type="text/xsl" href="%s"', - $stylesheet - ) - ); - $this->dom->appendChild($xslt); - - // Add root element "OAI-PMH". - $root = $this->dom->createElement('OAI-PMH'); - $this->dom->appendChild($root); - $root->setAttribute( - 'xmlns', - 'http://www.openarchives.org/OAI/2.0/' - ); - $root->setAttribute( - 'xmlns:xsi', - 'http://www.w3.org/2001/XMLSchema-instance' - ); - $root->setAttribute( - 'xsi:schemaLocation', - 'http://www.openarchives.org/OAI/2.0/ https://www.openarchives.org/OAI/2.0/OAI-PMH.xsd' - ); - - // Add element "responseDate". - $responseDate = $this->dom->createElement('responseDate', gmdate('Y-m-d\TH:i:s\Z')); - $root->appendChild($responseDate); - - // Add element "request". - $baseUrl = Uri::composeComponents( - $uri->getScheme(), - $uri->getAuthority(), - $uri->getPath(), - null, - null - ); - $request = $this->dom->createElement('request', $baseUrl); - $root->appendChild($request); - foreach ($serverRequest->getAttributes() as $param => $value) { - $request->setAttribute( - $param, - htmlspecialchars($value, ENT_XML1 | ENT_COMPAT, 'UTF-8') - ); - } - - $this->rootNode = $root; + $this->serverRequest = $serverRequest; + $this->createDocument(); + $this->appendRootElement(); + $this->appendResponseDate(); + $this->appendRequest(); } /**