diff --git a/.tools/phpstan/baseline/missingType.iterableValue.php b/.tools/phpstan/baseline/missingType.iterableValue.php index f87e478d7a..4104585a45 100644 --- a/.tools/phpstan/baseline/missingType.iterableValue.php +++ b/.tools/phpstan/baseline/missingType.iterableValue.php @@ -113,16 +113,6 @@ 'count' => 1, 'path' => __DIR__ . '/../../../src/Content/StructureContext.php', ]; -$ignoreErrors[] = [ - 'rawMessage' => 'Method Redaxo\\Core\\Content\\StructureElement::__construct() has parameter $params with no value type specified in iterable type array.', - 'count' => 1, - 'path' => __DIR__ . '/../../../src/Content/StructureElement.php', -]; -$ignoreErrors[] = [ - 'rawMessage' => 'Method Redaxo\\Core\\Content\\StructureElement::_hasValue() has parameter $prefixes with no value type specified in iterable type array.', - 'count' => 1, - 'path' => __DIR__ . '/../../../src/Content/StructureElement.php', -]; $ignoreErrors[] = [ 'rawMessage' => 'Method Redaxo\\Core\\Content\\StructureElement::getUrl() has parameter $params with no value type specified in iterable type array.', 'count' => 1, @@ -678,31 +668,6 @@ 'count' => 1, 'path' => __DIR__ . '/../../../src/View/View.php', ]; -$ignoreErrors[] = [ - 'rawMessage' => 'Method Redaxo\\Core\\Content\\Category@anonymous/tests/Content/CategoryTest.php:175::__construct() has parameter $params with no value type specified in iterable type array.', - 'count' => 1, - 'path' => __DIR__ . '/../../../tests/Content/CategoryTest.php', -]; -$ignoreErrors[] = [ - 'rawMessage' => 'Method Redaxo\\Core\\Tests\\Content\\CategoryTest::createCategories() has parameter $lev1Params with no value type specified in iterable type array.', - 'count' => 1, - 'path' => __DIR__ . '/../../../tests/Content/CategoryTest.php', -]; -$ignoreErrors[] = [ - 'rawMessage' => 'Method Redaxo\\Core\\Tests\\Content\\CategoryTest::createCategories() has parameter $lev2Params with no value type specified in iterable type array.', - 'count' => 1, - 'path' => __DIR__ . '/../../../tests/Content/CategoryTest.php', -]; -$ignoreErrors[] = [ - 'rawMessage' => 'Method Redaxo\\Core\\Tests\\Content\\CategoryTest::createCategories() has parameter $lev3Params with no value type specified in iterable type array.', - 'count' => 1, - 'path' => __DIR__ . '/../../../tests/Content/CategoryTest.php', -]; -$ignoreErrors[] = [ - 'rawMessage' => 'Method Redaxo\\Core\\Tests\\Content\\CategoryTest::createCategory() has parameter $params with no value type specified in iterable type array.', - 'count' => 1, - 'path' => __DIR__ . '/../../../tests/Content/CategoryTest.php', -]; $ignoreErrors[] = [ 'rawMessage' => 'Method Redaxo\\Core\\Tests\\MediaPool\\MediaPoolTest::testIsAllowedExtension() has parameter $args with no value type specified in iterable type array.', 'count' => 1, diff --git a/.tools/phpstan/baseline/missingType.parameter.php b/.tools/phpstan/baseline/missingType.parameter.php index ce9ee21a6a..def4ef2984 100644 --- a/.tools/phpstan/baseline/missingType.parameter.php +++ b/.tools/phpstan/baseline/missingType.parameter.php @@ -63,21 +63,6 @@ 'count' => 1, 'path' => __DIR__ . '/../../../src/Content/Linkmap/CategoryTree.php', ]; -$ignoreErrors[] = [ - 'rawMessage' => 'Method Redaxo\\Core\\Content\\Linkmap\\CategoryTreeRenderer::formatLi() has parameter $currentCategoryId with no type specified.', - 'count' => 1, - 'path' => __DIR__ . '/../../../src/Content/Linkmap/CategoryTreeRenderer.php', -]; -$ignoreErrors[] = [ - 'rawMessage' => 'Method Redaxo\\Core\\Content\\Linkmap\\CategoryTreeRenderer::formatLi() has parameter $liAttr with no type specified.', - 'count' => 1, - 'path' => __DIR__ . '/../../../src/Content/Linkmap/CategoryTreeRenderer.php', -]; -$ignoreErrors[] = [ - 'rawMessage' => 'Method Redaxo\\Core\\Content\\Linkmap\\CategoryTreeRenderer::formatLi() has parameter $linkAttr with no type specified.', - 'count' => 1, - 'path' => __DIR__ . '/../../../src/Content/Linkmap/CategoryTreeRenderer.php', -]; $ignoreErrors[] = [ 'rawMessage' => 'Method Redaxo\\Core\\Content\\Linkmap\\CategoryTreeRenderer::getTree() has parameter $categoryId with no type specified.', 'count' => 1, diff --git a/.tools/psalm/baseline.xml b/.tools/psalm/baseline.xml index 620c67b88e..a505cf08d8 100644 --- a/.tools/psalm/baseline.xml +++ b/.tools/psalm/baseline.xml @@ -1240,8 +1240,6 @@ - getValue('parent_id')]]> - getValue('parent_id')]]> @@ -1256,7 +1254,6 @@ getValue('updateuser'))]]> - @@ -1275,7 +1272,6 @@ - @@ -1832,9 +1828,6 @@ getValue('path')]]> getValue('path')]]> - - - @@ -1949,7 +1942,6 @@ - @@ -1966,10 +1958,6 @@ getValue('priority')]]> getValue('priority')]]> - - - - @@ -1999,10 +1987,6 @@ - - - - @@ -2024,18 +2008,6 @@ - - - - - - - - - - $val]]> - - getContentSections(), static fn (ContentSection $s) => $s->id === $id)]]> @@ -3474,10 +3446,6 @@ - - - - @@ -3508,9 +3476,6 @@ getSubject()]]> getSubject()]]> - - - @@ -3615,9 +3580,6 @@ - - - @@ -3627,9 +3589,6 @@ - - - @@ -4101,13 +4060,8 @@ - - - - - @@ -4180,9 +4134,6 @@ - - - diff --git a/pages/structure/content.metainfo.php b/pages/structure/content.metainfo.php index 09da3073b3..1368bb6cb1 100644 --- a/pages/structure/content.metainfo.php +++ b/pages/structure/content.metainfo.php @@ -45,7 +45,7 @@ $articleIcon = $articleStatusTypes[$status][2]; $structureContext = new StructureContext([ 'article_id' => Request::request('article_id', 'int'), - 'category_id' => $article->getCategoryId(), + 'category_id' => $article->categoryId, 'clang_id' => Request::request('clang', 'int'), ]); @@ -114,7 +114,7 @@ $formElements = []; $formElements[] = [ 'label' => '', - 'field' => '', + 'field' => '', ]; $fragment = new Fragment(); $fragment->setVar('elements', $formElements, false); diff --git a/pages/structure/content.php b/pages/structure/content.php index 14ee7d041d..7893e12347 100644 --- a/pages/structure/content.php +++ b/pages/structure/content.php @@ -68,8 +68,8 @@ } // ----- Artikel wurde gefunden - Kategorie holen -$OOArt = Article::get($articleId, $clang); -$categoryId = $OOArt->getCategoryId(); +$OOArt = Article::require($articleId, $clang); +$categoryId = $OOArt->categoryId; // ----- Request Parameter $subpage = Controller::getCurrentPagePart(2); @@ -86,13 +86,13 @@ ]); // ----- Titel anzeigen -echo View::title(I18n::msg('content') . ': ' . escape($OOArt->getName()), ''); +echo View::title(I18n::msg('content') . ': ' . escape($OOArt->name), ''); // ----- Languages echo View::clangSwitchAsButtons($context); // ----- category pfad und rechte -echo View::structureBreadcrumb($categoryId, $articleId, $clang); +echo View::structureBreadcrumb($categoryId ?? 0, $articleId, $clang); // ----- EXTENSION POINT echo Extension::registerPoint(new ExtensionPoint('STRUCTURE_CONTENT_HEADER', '', [ diff --git a/pages/structure/index.php b/pages/structure/index.php index 3af1ade6d3..79e1cda949 100644 --- a/pages/structure/index.php +++ b/pages/structure/index.php @@ -81,7 +81,7 @@ $catName = I18n::msg('root_level'); $category = Category::get($structureContext->getCategoryId(), $structureContext->getClangId()); if ($category) { - $catName = $category->getName(); + $catName = $category->name; } $addCategory = ''; diff --git a/pages/structure/linkmap.php b/pages/structure/linkmap.php index a521c84e3c..9778f047d5 100644 --- a/pages/structure/linkmap.php +++ b/pages/structure/linkmap.php @@ -112,8 +112,8 @@ function insertLink(link,name){ if ($category) { foreach ($category->getParentTree() as $parent) { $n = []; - $n['title'] = str_replace(' ', ' ', escape($parent->getName())); - $n['href'] = $context->getUrl(['category_id' => $parent->getId()]); + $n['title'] = str_replace(' ', ' ', escape($parent->name)); + $n['href'] = $context->getUrl(['category_id' => $parent->id]); $navigation[] = $n; } } @@ -137,7 +137,7 @@ function insertLink(link,name){ $fragment->setVar('content', $panel, false); $content[] = $fragment->parse('core/page/section.php'); -$articleList = new ArticleList($context); +$articleList = new ArticleList(); $panel = $articleList->getList($categoryId); $fragment = new Fragment(); diff --git a/rector.php b/rector.php index 015f273e97..afe374640c 100644 --- a/rector.php +++ b/rector.php @@ -533,6 +533,10 @@ new MethodCallToPropertyFetch(Cronjob\CronjobExecutor::class, 'getMessage', 'message'), new MethodCallToPropertyFetch(Cronjob\CronjobExecutor::class, 'hasMessage', 'message'), + new MethodCallToPropertyFetch(Content\Article::class, 'getCategoryId', 'categoryId'), // changed from int to ?int + new MethodCallToPropertyFetch(Content\Article::class, 'getTemplateKey', 'templateKey'), + new MethodCallToPropertyFetch(Content\Article::class, 'hasTemplate', 'templateKey'), // changed from bool to ?string, callers using the bool need manual adjustment + new MethodCallToPropertyFetch(Content\ArticleSlice::class, 'getId', 'id'), new MethodCallToPropertyFetch(Content\ArticleSlice::class, 'getArticleId', 'articleId'), new MethodCallToPropertyFetch(Content\ArticleSlice::class, 'getClang', 'clangId'), @@ -548,6 +552,18 @@ new MethodCallToPropertyFetch(Content\ContentSection::class, 'getId', 'id'), new MethodCallToPropertyFetch(Content\ContentSection::class, 'getName', 'name'), + new MethodCallToPropertyFetch(Content\StructureElement::class, 'getId', 'id'), + new MethodCallToPropertyFetch(Content\StructureElement::class, 'getParentId', 'parentId'), + new MethodCallToPropertyFetch(Content\StructureElement::class, 'getClangId', 'clangId'), + new MethodCallToPropertyFetch(Content\StructureElement::class, 'getName', 'name'), + new MethodCallToPropertyFetch(Content\StructureElement::class, 'getPriority', 'priority'), + new MethodCallToPropertyFetch(Content\StructureElement::class, 'getPath', 'path'), // changed from string to array, callers using the string need manual adjustment + new MethodCallToPropertyFetch(Content\StructureElement::class, 'getPathAsArray', 'path'), + new MethodCallToPropertyFetch(Content\StructureElement::class, 'getCreateDate', 'createDate'), + new MethodCallToPropertyFetch(Content\StructureElement::class, 'getUpdateDate', 'updateDate'), + new MethodCallToPropertyFetch(Content\StructureElement::class, 'getCreateUser', 'createUser'), + new MethodCallToPropertyFetch(Content\StructureElement::class, 'getUpdateUser', 'updateUser'), + new MethodCallToPropertyFetch(Language\Language::class, 'getId', 'id'), new MethodCallToPropertyFetch(Language\Language::class, 'getCode', 'code'), new MethodCallToPropertyFetch(Language\Language::class, 'getName', 'name'), diff --git a/src/Cache.php b/src/Cache.php index 2796fb8429..6f346c5578 100644 --- a/src/Cache.php +++ b/src/Cache.php @@ -35,7 +35,6 @@ public static function delete(): string StructureElement::clearInstancePool(); StructureElement::clearInstanceListPool(); - StructureElement::resetClassVars(); if (function_exists('opcache_reset')) { opcache_reset(); diff --git a/src/Content/ApiFunction/ArticleCopy.php b/src/Content/ApiFunction/ArticleCopy.php index 4b96a30c43..47c551087c 100644 --- a/src/Content/ApiFunction/ArticleCopy.php +++ b/src/Content/ApiFunction/ArticleCopy.php @@ -44,7 +44,7 @@ public function execute(): Result } if ( - !$user->getComplexPerm('structure')->hasCategoryPerm($article->getCategoryId()) + !$user->getComplexPerm('structure')->hasCategoryPerm($article->categoryId) || !$user->getComplexPerm('structure')->hasCategoryPerm($categoryCopyIdNew) ) { throw new ApiFunctionException(I18n::msg('no_rights_to_this_function')); diff --git a/src/Content/ApiFunction/ArticleDelete.php b/src/Content/ApiFunction/ArticleDelete.php index b1cea7a9ab..b8afefe82f 100644 --- a/src/Content/ApiFunction/ArticleDelete.php +++ b/src/Content/ApiFunction/ArticleDelete.php @@ -32,7 +32,7 @@ public function execute(): Result throw new ApiFunctionException('Unable to find article with id "' . $articleId . '"!'); } - if (!$user->getComplexPerm('structure')->hasCategoryPerm($article->getCategoryId())) { + if (!$user->getComplexPerm('structure')->hasCategoryPerm($article->categoryId)) { throw new ApiFunctionException(I18n::msg('no_rights_to_this_function')); } diff --git a/src/Content/ApiFunction/ArticleEdit.php b/src/Content/ApiFunction/ArticleEdit.php index af7025f72d..f4fffa7a32 100644 --- a/src/Content/ApiFunction/ArticleEdit.php +++ b/src/Content/ApiFunction/ArticleEdit.php @@ -35,7 +35,7 @@ public function execute(): Result if ( !$user->getComplexPerm('clang')->hasPerm($clang) - || !$user->getComplexPerm('structure')->hasCategoryPerm($article->getCategoryId()) + || !$user->getComplexPerm('structure')->hasCategoryPerm($article->categoryId) ) { throw new ApiFunctionException(I18n::msg('no_rights_to_this_function')); } diff --git a/src/Content/ApiFunction/ArticleMove.php b/src/Content/ApiFunction/ArticleMove.php index 1070b1f376..b4499dab48 100644 --- a/src/Content/ApiFunction/ArticleMove.php +++ b/src/Content/ApiFunction/ArticleMove.php @@ -40,7 +40,7 @@ public function execute(): Result throw new ApiFunctionException('Unable to find category with id "' . $categoryIdNew . '"!'); } - $categoryId = $article->getCategoryId(); + $categoryId = $article->categoryId ?? 0; if ( !$user->getComplexPerm('structure')->hasCategoryPerm($categoryId) diff --git a/src/Content/ApiFunction/ArticleSliceMove.php b/src/Content/ApiFunction/ArticleSliceMove.php index cdcbfccf6d..78b3da3f43 100644 --- a/src/Content/ApiFunction/ArticleSliceMove.php +++ b/src/Content/ApiFunction/ArticleSliceMove.php @@ -50,7 +50,7 @@ public function execute(): Result if ( !$user->getComplexPerm('clang')->hasPerm($clang) - || !$user->getComplexPerm('structure')->hasCategoryPerm($article->getCategoryId()) + || !$user->getComplexPerm('structure')->hasCategoryPerm($article->categoryId) || !$user->getComplexPerm('modules')->hasPerm($moduleKey) ) { throw new ApiFunctionException(I18n::msg('no_rights_to_this_function')); diff --git a/src/Content/ApiFunction/ArticleSliceStatusChange.php b/src/Content/ApiFunction/ArticleSliceStatusChange.php index 0bf838126f..54db482af9 100644 --- a/src/Content/ApiFunction/ArticleSliceStatusChange.php +++ b/src/Content/ApiFunction/ArticleSliceStatusChange.php @@ -35,7 +35,7 @@ public function execute(): Result if ( !$user->getComplexPerm('clang')->hasPerm($clang) - || !$user->getComplexPerm('structure')->hasCategoryPerm($article->getCategoryId()) + || !$user->getComplexPerm('structure')->hasCategoryPerm($article->categoryId) ) { throw new ApiFunctionException(I18n::msg('no_rights_to_this_function')); } diff --git a/src/Content/ApiFunction/ArticleStatusChange.php b/src/Content/ApiFunction/ArticleStatusChange.php index ca3256fda3..3241543977 100644 --- a/src/Content/ApiFunction/ArticleStatusChange.php +++ b/src/Content/ApiFunction/ArticleStatusChange.php @@ -36,7 +36,7 @@ public function execute(): Result if ( !$user->getComplexPerm('clang')->hasPerm($clang) - || !$user->getComplexPerm('structure')->hasCategoryPerm($article->getCategoryId()) + || !$user->getComplexPerm('structure')->hasCategoryPerm($article->categoryId) ) { throw new ApiFunctionException(I18n::msg('no_rights_to_this_function')); } diff --git a/src/Content/ApiFunction/ArticleToCategory.php b/src/Content/ApiFunction/ArticleToCategory.php index 2b3c2352e0..5dfba086b8 100644 --- a/src/Content/ApiFunction/ArticleToCategory.php +++ b/src/Content/ApiFunction/ArticleToCategory.php @@ -32,7 +32,7 @@ public function execute(): Result throw new ApiFunctionException('Unable to find article with id "' . $articleId . '"!'); } - if (!$user->getComplexPerm('structure')->hasCategoryPerm($article->getCategoryId())) { + if (!$user->getComplexPerm('structure')->hasCategoryPerm($article->categoryId)) { throw new ApiFunctionException(I18n::msg('no_rights_to_this_function')); } diff --git a/src/Content/ApiFunction/ArticleToStartArticle.php b/src/Content/ApiFunction/ArticleToStartArticle.php index c7f3d193e6..58b6ce2318 100644 --- a/src/Content/ApiFunction/ArticleToStartArticle.php +++ b/src/Content/ApiFunction/ArticleToStartArticle.php @@ -32,7 +32,7 @@ public function execute(): Result throw new ApiFunctionException('Unable to find article with id "' . $articleId . '"!'); } - if (!$user->getComplexPerm('structure')->hasCategoryPerm($article->getCategoryId())) { + if (!$user->getComplexPerm('structure')->hasCategoryPerm($article->categoryId)) { throw new ApiFunctionException(I18n::msg('no_rights_to_this_function')); } diff --git a/src/Content/ApiFunction/CategoryToArticle.php b/src/Content/ApiFunction/CategoryToArticle.php index 2d6d2f2957..73faedea94 100644 --- a/src/Content/ApiFunction/CategoryToArticle.php +++ b/src/Content/ApiFunction/CategoryToArticle.php @@ -33,7 +33,7 @@ public function execute(): Result throw new ApiFunctionException('Unable to find article with id "' . $articleId . '"!'); } - if (!$user->getComplexPerm('structure')->hasCategoryPerm($article->getCategoryId())) { + if (!$user->getComplexPerm('structure')->hasCategoryPerm($article->categoryId)) { throw new ApiFunctionException(I18n::msg('no_rights_to_this_function')); } diff --git a/src/Content/ApiFunction/ContentCopy.php b/src/Content/ApiFunction/ContentCopy.php index 982e5059ba..4086c29d3f 100644 --- a/src/Content/ApiFunction/ContentCopy.php +++ b/src/Content/ApiFunction/ContentCopy.php @@ -38,7 +38,7 @@ public function execute(): Result if ( !$user->getComplexPerm('clang')->hasPerm($clangA) || !$user->getComplexPerm('clang')->hasPerm($clangB) - || !$user->getComplexPerm('structure')->hasCategoryPerm($article->getCategoryId()) + || !$user->getComplexPerm('structure')->hasCategoryPerm($article->categoryId) ) { throw new ApiFunctionException(I18n::msg('no_rights_to_this_function')); } diff --git a/src/Content/Article.php b/src/Content/Article.php index ce80cc0aba..3e3859ca94 100644 --- a/src/Content/Article.php +++ b/src/Content/Article.php @@ -2,77 +2,111 @@ namespace Redaxo\Core\Content; +use Override; use Redaxo\Core\Core; use Redaxo\Core\ExtensionPoint\Extension; use Redaxo\Core\ExtensionPoint\ExtensionPoint; +use function in_array; + /** - * Object Oriented Framework: Bildet einen Artikel der Struktur ab. + * Bildet einen Artikel der Struktur ab. */ -class Article extends StructureElement +final class Article extends StructureElement { - /** - * Return the current article id. - * - * @return int - */ - public static function getCurrentId() + protected string $metaInfoPrefix = 'art_'; + + public readonly ?int $categoryId; + public readonly ?string $templateKey; + public readonly bool $startArticle; + + /** @param array $data */ + private function __construct(array $data) + { + // strip irrelevant + Category-only fields up front; the rest gets explicitly + // pulled into typed properties below, what remains lands in additionalData. + unset($data['pid'], $data['catname'], $data['catpriority']); + foreach (array_keys($data) as $key) { + if (str_starts_with((string) $key, 'cat_')) { + unset($data[$key]); + } + } + + $getAndUnset = static function (string $key) use (&$data): string|int|null { + $value = $data[$key] ?? null; + unset($data[$key]); + return $value; + }; + + $id = (int) $getAndUnset('id'); + $parentId = (int) $getAndUnset('parent_id'); + $startArticle = (bool) $getAndUnset('startarticle'); + $path = array_map('intval', array_filter(explode('|', (string) $getAndUnset('path')))); + // start-articles share the cache row with their category; their DB path + // only includes ancestor categories, so add their own id to keep the + // semantic "all category ids on the way to this element" + if ($startArticle) { + $path[] = $id; + } + + parent::__construct( + id: $id, + clangId: (int) $getAndUnset('clang_id'), + name: (string) $getAndUnset('name'), + priority: (int) $getAndUnset('priority'), + path: array_values($path), + status: (int) $getAndUnset('status'), + createDate: (int) $getAndUnset('createdate'), + updateDate: (int) $getAndUnset('updatedate'), + createUser: (string) $getAndUnset('createuser'), + updateUser: (string) $getAndUnset('updateuser'), + additionalData: $data, + ); + + $this->categoryId = $startArticle ? $id : ($parentId > 0 ? $parentId : null); + $this->templateKey = null === ($t = $getAndUnset('template')) ? null : (string) $t; + $this->startArticle = $startArticle; + } + + /** @param array $data */ + #[Override] + protected static function fromCache(array $data): static + { + return new self($data); + } + + /** Return the current article id. */ + public static function getCurrentId(): int { return Core::getProperty('article_id', 1); } - /** - * Return the current article. - * - * @param int $clang - * - * @return self|null - */ - public static function getCurrent($clang = null) + /** Return the current article. */ + public static function getCurrent(?int $clang = null): ?self { return self::get(self::getCurrentId(), $clang); } - /** - * Return the site wide start article id. - * - * @return int - */ - public static function getSiteStartArticleId() + /** Return the site wide start article id. */ + public static function getSiteStartArticleId(): int { return Core::getProperty('start_article_id', 1); } - /** - * Return the site wide start article. - * - * @param int $clang - * - * @return self|null - */ - public static function getSiteStartArticle($clang = null) + /** Return the site wide start article. */ + public static function getSiteStartArticle(?int $clang = null): ?self { return self::get(self::getSiteStartArticleId(), $clang); } - /** - * Return the site wide notfound article id. - * - * @return int - */ - public static function getNotfoundArticleId() + /** Return the site wide notfound article id. */ + public static function getNotfoundArticleId(): int { return Core::getProperty('notfound_article_id', 1); } - /** - * Return the site wide notfound article. - * - * @param int $clang - * - * @return self|null - */ - public static function getNotfoundArticle($clang = null) + /** Return the site wide notfound article. */ + public static function getNotfoundArticle(?int $clang = null): ?self { return self::get(self::getNotfoundArticleId(), $clang); } @@ -80,81 +114,68 @@ public static function getNotfoundArticle($clang = null) /** * Return a list of top-level articles. * - * @param bool $ignoreOfflines - * @param int $clang - * * @return list */ - public static function getRootArticles($ignoreOfflines = false, $clang = null) + public static function getRootArticles(bool $ignoreOfflines = false, ?int $clang = null): array { return self::getChildElements(0, 'alist', $ignoreOfflines, $clang); } - /** - * Returns the category id. - * - * @return int - */ - public function getCategoryId() + /** Returns the category this article belongs to (for start-articles the category itself). */ + public function getCategory(): ?Category { - return $this->isStartArticle() ? $this->getId() : $this->getParentId(); + return null === $this->categoryId ? null : Category::get($this->categoryId, $this->clangId); } - /** - * Returns the parent category. - * - * @return Category|null - */ - public function getCategory() + #[Override] + protected function getParent(): ?Category { - return Category::get($this->getCategoryId(), $this->getClangId()); + $category = $this->getCategory(); + return $this->startArticle ? $category?->getParent() : $category; } - /** - * Returns the parent object of the article. - * - * @return static|null - */ - public function getParent() + /** Returns true if this article is the start-article for its category. */ + public function isStartArticle(): bool { - return self::get($this->parent_id, $this->clang_id); + return $this->startArticle; } - /** - * Returns the path of the category/article. - * - * @return string - */ - public function getPath() + /** Returns true if this article is the site-wide start article. */ + public function isSiteStartArticle(): bool { - if ($this->isStartArticle()) { - return $this->path . $this->id . '|'; - } + return $this->id === self::getSiteStartArticleId(); + } - return $this->path; + /** Returns true if this article is the site-wide not-found article. */ + public function isNotFoundArticle(): bool + { + return $this->id === self::getNotfoundArticleId(); } - public function getValue($value) + #[Override] + public function getValue(string $key): string|int|null { - if ('category_id' === $value) { - // für die CatId hier den Getter verwenden, - // da dort je nach ArtikelTyp unterscheidungen getroffen werden müssen - return $this->getCategoryId(); - } - return parent::getValue($value); + $key = strtolower($key); + + return match ($key) { + 'category_id' => $this->categoryId, + 'template' => $this->templateKey, + 'startarticle' => (int) $this->startArticle, + default => parent::getValue($key), + }; } - /** - * @param string $value - * - * @return bool - */ - public static function hasValue($value) + #[Override] + public function hasValue(string $key): bool { - return parent::_hasValue($value, ['art_']); + $key = strtolower($key); + + return in_array($key, ['template', 'startarticle', 'category_id'], true) + || parent::hasValue($key); } - public function isPermitted() + #[Override] + public function isPermitted(): bool { return (bool) Extension::registerPoint(new ExtensionPoint('ART_IS_PERMITTED', true, ['element' => $this])); } diff --git a/src/Content/ArticleCache.php b/src/Content/ArticleCache.php index 2c8be6bc5b..09dd3452ed 100644 --- a/src/Content/ArticleCache.php +++ b/src/Content/ArticleCache.php @@ -152,7 +152,7 @@ public static function generateMeta($articleId, $clangId = null) $clang = $row->getValue('clang_id'); // --------------------------------------------------- Artikelparameter speichern - $params = ['last_update_stamp' => time()]; + $params = []; foreach ($fieldnames as $field) { $params[$field] = match ($field) { 'createdate', 'updatedate' => $row->getDateTimeValue($field), diff --git a/src/Content/ArticleContent.php b/src/Content/ArticleContent.php index 80bf08fe2f..13323f445b 100644 --- a/src/Content/ArticleContent.php +++ b/src/Content/ArticleContent.php @@ -61,8 +61,8 @@ public function setArticleId($articleId) $rexArticle = Article::get($articleId, $this->clang); if ($rexArticle instanceof Article) { - $this->category_id = $rexArticle->getCategoryId(); - $this->template = $rexArticle->getTemplateKey(); + $this->category_id = $rexArticle->categoryId ?? 0; + $this->template = $rexArticle->templateKey; return true; } @@ -81,16 +81,16 @@ public function getValue($value) $value = $this->correctValue($value); - if (!Article::hasValue($value)) { - throw new LogicException('Articles do not have the property "' . $value . '"'); - } - $article = Article::get($this->article_id, $this->clang); if (!$article) { throw new LogicException('Article for id=' . $this->article_id . ' and clang=' . $this->clang . ' does not exist'); } + if (!$article->hasValue($value)) { + throw new LogicException('Articles do not have the property "' . $value . '"'); + } + return $article->getValue($value); } @@ -103,7 +103,7 @@ public function hasValue($value) $value = $this->correctValue($value); - return Article::hasValue($value); + return Article::get($this->article_id, $this->clang)?->hasValue($value) ?? false; } public function getArticle($curctype = -1) diff --git a/src/Content/ArticleHandler.php b/src/Content/ArticleHandler.php index f028866a3c..6f684fa8d1 100644 --- a/src/Content/ArticleHandler.php +++ b/src/Content/ArticleHandler.php @@ -41,8 +41,7 @@ public static function addArticle(array $data): string if (!$parent) { throw new ApiFunctionException('Target category with ID "' . $data['category_id'] . '" does not exist.'); } - $path = $parent->getPath(); - $path .= $parent->getId() . '|'; + $path = '|' . implode('|', [...$parent->path, $parent->id]) . '|'; } else { $path = '|'; } @@ -68,7 +67,7 @@ public static function addArticle(array $data): string $categoryName = ''; if ($category) { - $categoryName = $category->getName(); + $categoryName = $category->name; } $AART->setTable(Core::getTablePrefix() . 'article'); @@ -136,8 +135,8 @@ public static function editArticle(int $articleId, int $clang, array $data): str throw new ApiFunctionException('Unable to find article with id "' . $articleId . '" and clang "' . $clang . '"!'); } - $ooArt = Article::get($articleId, $clang); - $data['category_id'] = $ooArt->getCategoryId(); + $ooArt = Article::require($articleId, $clang); + $data['category_id'] = $ooArt->categoryId; $templates = Template::getTemplatesForCategory($data['category_id']); @@ -413,7 +412,7 @@ public static function prevStatus($currentStatus) /** * Berechnet die Prios der Artikel in einer Kategorie neu. * - * @param int $parentId KategorieId der Kategorie, die erneuert werden soll + * @param int|null $parentId KategorieId der Kategorie, die erneuert werden soll * @param int $clang ClangId der Kategorie, die erneuert werden soll * @param int $newPrio Neue PrioNr der Kategorie * @param int $oldPrio Alte PrioNr der Kategorie @@ -586,8 +585,7 @@ public static function article2startarticle($neuId) // cat felder sammeln. + $params = ['path', 'priority', 'catname', 'startarticle', 'catpriority', 'status']; - $dbFields = StructureElement::getClassVars(); - foreach ($dbFields as $field) { + foreach ($alt->getFieldnames() as $field) { if (str_starts_with($field, 'cat_')) { $params[] = $field; } diff --git a/src/Content/Category.php b/src/Content/Category.php index cf9ac85d34..70ef987312 100644 --- a/src/Content/Category.php +++ b/src/Content/Category.php @@ -2,28 +2,78 @@ namespace Redaxo\Core\Content; +use Override; use Redaxo\Core\ExtensionPoint\Extension; use Redaxo\Core\ExtensionPoint\ExtensionPoint; -use function assert; +use function in_array; /** - * Object Oriented Framework: Bildet eine Kategorie der Struktur ab. + * Bildet eine Kategorie der Struktur ab. */ -class Category extends StructureElement +final class Category extends StructureElement { - /** - * Return the current category. - * - * @param int $clang - * - * @return self|null - */ - public static function getCurrent($clang = null) + protected string $metaInfoPrefix = 'cat_'; + + public readonly ?int $parentId; + + /** @param array $data */ + private function __construct(array $data) + { + // strip irrelevant + Article-only fields up front + unset( + $data['pid'], + $data['name'], $data['priority'], $data['template'], $data['startarticle'], + ); + foreach (array_keys($data) as $key) { + if (str_starts_with((string) $key, 'art_')) { + unset($data[$key]); + } + } + + $getAndUnset = static function (string $key) use (&$data): string|int|null { + $value = $data[$key] ?? null; + unset($data[$key]); + return $value; + }; + + $parentId = (int) $getAndUnset('parent_id'); + + parent::__construct( + id: (int) $getAndUnset('id'), + clangId: (int) $getAndUnset('clang_id'), + name: (string) $getAndUnset('catname'), + priority: (int) $getAndUnset('catpriority'), + path: array_values(array_map('intval', array_filter(explode('|', (string) $getAndUnset('path'))))), + status: (int) $getAndUnset('status'), + createDate: (int) $getAndUnset('createdate'), + updateDate: (int) $getAndUnset('updatedate'), + createUser: (string) $getAndUnset('createuser'), + updateUser: (string) $getAndUnset('updateuser'), + additionalData: $data, + ); + + $this->parentId = $parentId > 0 ? $parentId : null; + } + + /** @param array $data */ + #[Override] + protected static function fromCache(array $data): ?static + { + // categories only exist for start-articles (which share their cache row) + if (!$data['startarticle']) { + return null; + } + + return new self($data); + } + + /** Return the current category. */ + public static function getCurrent(?int $clang = null): ?self { $article = Article::getCurrent($clang); - return $article ? $article->getCategory() : null; + return $article?->getCategory(); } /** @@ -35,21 +85,13 @@ public static function getCurrent($clang = null) * all categories with status 0 will be * excempt from this list! * - * @param bool $ignoreOfflines - * @param int $clang - * * @return list */ - public static function getRootCategories($ignoreOfflines = false, $clang = null) + public static function getRootCategories(bool $ignoreOfflines = false, ?int $clang = null): array { return self::getChildElements(0, 'clist', $ignoreOfflines, $clang); } - public function getPriority() - { - return $this->catpriority; - } - /** * Return a list of all subcategories. * Returns an array of Category objects sorted by $priority. @@ -58,35 +100,24 @@ public function getPriority() * all categories with status 0 will be * excempt from this list! * - * @param bool $ignoreOfflines - * * @return list */ - public function getChildren($ignoreOfflines = false) + public function getChildren(bool $ignoreOfflines = false): array { - return self::getChildElements($this->id, 'clist', $ignoreOfflines, $this->clang_id); + return self::getChildElements($this->id, 'clist', $ignoreOfflines, $this->clangId); } - /** - * Returns the parent category. - * - * @return static|null - */ - public function getParent() + /** Returns the parent category. */ + #[Override] + public function getParent(): ?self { - return self::get($this->parent_id, $this->clang_id); + return null === $this->parentId ? null : self::get($this->parentId, $this->clangId); } - /** - * Returns TRUE if this category is the direct - * parent of the other category. - * - * @return bool - */ - public function isParent(self $otherCat) + /** Returns TRUE if this category is the direct parent of the other category. */ + public function isParent(self $otherCat): bool { - return $this->getId() == $otherCat->getParentId() - && $this->getClangId() == $otherCat->getClangId(); + return $this->id === $otherCat->parentId && $this->clangId === $otherCat->clangId; } /** @@ -97,58 +128,41 @@ public function isParent(self $otherCat) * all articles with status 0 will be * excempt from this list! * - * @param bool $ignoreOfflines - * * @return list
*/ - public function getArticles($ignoreOfflines = false) + public function getArticles(bool $ignoreOfflines = false): array { - return Article::getChildElements($this->id, 'alist', $ignoreOfflines, $this->clang_id); + return Article::getChildElements($this->id, 'alist', $ignoreOfflines, $this->clangId); } - /** - * Return the start article for this category. - * - * @return Article - */ - public function getStartArticle() + /** Return the start article for this category. */ + public function getStartArticle(): Article { - $article = Article::get($this->id, $this->clang_id); - assert($article instanceof Article); - return $article; + return Article::require($this->id, $this->clangId); } - /** - * Returns the name of the category. - * - * @return string - */ - public function getName() + #[Override] + public function getValue(string $key): string|int|null { - return $this->catname; + return match (strtolower($key)) { + 'catname' => $this->name, + 'catpriority' => $this->priority, + 'parent_id' => $this->parentId, + default => parent::getValue($key), + }; } - /** - * Returns the path of the category. - * - * @return string - */ - public function getPath() + #[Override] + public function hasValue(string $key): bool { - return $this->path; - } + $key = strtolower($key); - /** - * @param string $value - * - * @return bool - */ - public static function hasValue($value) - { - return parent::_hasValue($value, ['cat_']); + return in_array($key, ['catname', 'catpriority', 'parent_id'], true) + || parent::hasValue($key); } - public function isPermitted() + #[Override] + public function isPermitted(): bool { return (bool) Extension::registerPoint(new ExtensionPoint('CAT_IS_PERMITTED', true, ['element' => $this])); } diff --git a/src/Content/CategoryHandler.php b/src/Content/CategoryHandler.php index 5c30e5c43c..aa9281e361 100644 --- a/src/Content/CategoryHandler.php +++ b/src/Content/CategoryHandler.php @@ -42,8 +42,7 @@ public static function addCategory($categoryId, array $data) if (!$parent) { throw new ApiFunctionException('Target category with ID "' . $categoryId . '" does not exist.'); } - $path = $parent->getPath(); - $path .= $parent->getId() . '|'; + $path = '|' . implode('|', [...$parent->path, $parent->id]) . '|'; } else { $path = '|'; } diff --git a/src/Content/ContentHandler.php b/src/Content/ContentHandler.php index 368cbf8684..83a9136464 100644 --- a/src/Content/ContentHandler.php +++ b/src/Content/ContentHandler.php @@ -66,7 +66,7 @@ public static function addSlice(int $articleId, int $clangId, int $ctypeId, stri $message = I18n::msg('slice_added'); - $article = Article::get($articleId, $clangId); + $article = Article::require($articleId, $clangId); // ----- EXTENSION POINT $message = Extension::registerPoint(new ExtensionPoint('SLICE_ADDED', $message, [ @@ -76,7 +76,7 @@ public static function addSlice(int $articleId, int $clangId, int $ctypeId, stri 'slice_id' => $sliceId, 'page' => Controller::getCurrentPage(), 'ctype' => $ctypeId, - 'category_id' => $article->getCategoryId(), + 'category_id' => $article->categoryId, 'module_key' => $moduleKey, 'article_revision' => 0, 'slice_revision' => $data['revision'], @@ -218,14 +218,14 @@ public static function sliceStatus(int $sliceId, int $status) throw new RuntimeException(sprintf('Slice with id=%d not found.', $sliceId)); } - $article = Article::get($sql->getValue('article_id'), $sql->getValue('clang_id')); + $article = Article::require($sql->getValue('article_id'), $sql->getValue('clang_id')); $sql->setTable(Core::getTable('article_slice')); $sql->setWhere(['id' => $sliceId]); $sql->setValue('status', $status); $sql->update(); - ArticleCache::deleteContent($article->getId(), $article->getClangId()); + ArticleCache::deleteContent($article->id, $article->clangId); Extension::registerPoint(new ArticleContentUpdated($article, 'slice_status')); } @@ -335,7 +335,7 @@ public static function copyContent($fromId, $toId, $fromClang = 1, $toClang = 1, ArticleCache::deleteContent($toId, $toClang); - $article = Article::get($toId, $toClang); + $article = Article::require($toId, $toClang); Extension::registerPoint(new ArticleContentUpdated($article, 'content_copied')); return true; diff --git a/src/Content/ExtensionPoint/ArticleContentUpdated.php b/src/Content/ExtensionPoint/ArticleContentUpdated.php index d623b651b1..8d0356419a 100644 --- a/src/Content/ExtensionPoint/ArticleContentUpdated.php +++ b/src/Content/ExtensionPoint/ArticleContentUpdated.php @@ -19,8 +19,8 @@ class ArticleContentUpdated extends ExtensionPoint public function __construct(Article $article, string $action, string $subject = '', array $params = [], bool $readonly = false) { // for BC 'simple' attach params - $params['article_id'] = $article->getId(); - $params['clang'] = $article->getClangId(); + $params['article_id'] = $article->id; + $params['clang'] = $article->clangId; parent::__construct(self::NAME, $subject, $params, $readonly); diff --git a/src/Content/Linkmap/ArticleList.php b/src/Content/Linkmap/ArticleList.php index 817bd810ce..cc2294b411 100644 --- a/src/Content/Linkmap/ArticleList.php +++ b/src/Content/Linkmap/ArticleList.php @@ -3,7 +3,6 @@ namespace Redaxo\Core\Content\Linkmap; use Redaxo\Core\Content\Article; -use Redaxo\Core\Http\Context; use function Redaxo\Core\View\escape; use function sprintf; @@ -13,15 +12,26 @@ */ class ArticleList extends ArticleListRenderer { - public function __construct( - private Context $context, - ) {} - /** @return string */ protected function listItem(Article $article, $categoryId) { - $liAttr = ' class="list-group-item rex-linkmap-list-item-article"'; - $url = 'javascript:insertLink(\'redaxo://' . $article->getId() . '\',\'' . escape(trim(sprintf('%s [%s]', $article->getName(), $article->getId())), 'js') . '\');'; - return CategoryTreeRenderer::formatLi($article, $categoryId, $this->context, $liAttr, ' href="' . $url . '"') . '' . "\n"; + $url = 'javascript:insertLink(\'redaxo://' . $article->id . '\',\'' . escape(trim(sprintf('%s [%s]', $article->name, $article->id)), 'js') . '\');'; + + $linkClass = $article->isOnline() ? 'rex-online' : 'rex-offline'; + $label = CategoryTreeRenderer::formatLabel($article); + + $iconType = match (true) { + $article->isSiteStartArticle() => 'sitestartarticle', + $article->isStartArticle() => 'startarticle', + default => 'article', + }; + + return '
  • ' + . '' + . ' ' + . escape($label) + . '' . $article->id . '' + . '' + . '
  • ' . "\n"; } } diff --git a/src/Content/Linkmap/CategoryTree.php b/src/Content/Linkmap/CategoryTree.php index 40ddebaf4b..8fc3656c63 100644 --- a/src/Content/Linkmap/CategoryTree.php +++ b/src/Content/Linkmap/CategoryTree.php @@ -34,7 +34,7 @@ protected function treeItem(Category $cat, $liClasses, $linkClasses, $subHtml, $ $badgeCat = ($countChildren > 0) ? '' . $countChildren . '' : ''; $li = ''; $li .= ''; - $li .= '' . $liIcon . escape($label) . '' . $cat->getId() . ''; + $li .= '' . $liIcon . escape($label) . '' . $cat->id . ''; $li .= $badgeCat; $li .= $subHtml; $li .= '' . "\n"; diff --git a/src/Content/Linkmap/CategoryTreeRenderer.php b/src/Content/Linkmap/CategoryTreeRenderer.php index 0303efad4d..d046af44c1 100644 --- a/src/Content/Linkmap/CategoryTreeRenderer.php +++ b/src/Content/Linkmap/CategoryTreeRenderer.php @@ -6,12 +6,10 @@ use Redaxo\Core\Content\Category; use Redaxo\Core\Content\StructureElement; use Redaxo\Core\Core; -use Redaxo\Core\Http\Context; use Redaxo\Core\Translation\I18n; use function count; use function in_array; -use function Redaxo\Core\View\escape; /** * @internal @@ -39,7 +37,7 @@ public function getTree($categoryId) $tree = []; if ($category) { foreach ($category->getParentTree() as $cat) { - $tree[] = $cat->getId(); + $tree[] = $cat->id; } } @@ -62,7 +60,7 @@ public function renderTree(array $children, array $activeTreeIds) $li = ''; foreach ($children as $cat) { $catChildren = $cat->getChildren(); - $catId = $cat->getId(); + $catId = $cat->id; $liclasses = 'list-group-item'; $linkclasses = ''; $subLi = ''; @@ -79,7 +77,7 @@ public function renderTree(array $children, array $activeTreeIds) } if ('' != $li) { - $ul = '
      ' . "\n" . $li . '
    ' . "\n"; + $ul = '
      ' . "\n" . $li . '
    ' . "\n"; } return $ul; @@ -88,35 +86,18 @@ public function renderTree(array $children, array $activeTreeIds) /** @return string */ abstract protected function treeItem(Category $cat, $liClasses, $linkClasses, $subHtml, $liIcon); - /** @return string */ - public static function formatLabel(StructureElement $OOobject) + public static function formatLabel(StructureElement $OOobject): string { - $label = $OOobject->getName(); + $label = $OOobject->name; - if ('' == trim($label)) { + if ('' === trim($label)) { $label = ' '; } - if ($OOobject instanceof Article && !$OOobject->hasTemplate()) { + if ($OOobject instanceof Article && null === $OOobject->templateKey) { $label .= ' [' . I18n::msg('linkmap_has_no_template') . ']'; } return $label; } - - /** @return string */ - public static function formatLi(StructureElement $OOobject, $currentCategoryId, Context $context, $liAttr = '', $linkAttr = '') - { - $linkAttr .= ' class="' . ($OOobject->isOnline() ? 'rex-online' : 'rex-offline') . '"'; - - if (!str_contains($linkAttr, ' href=')) { - $linkAttr .= ' href="' . $context->getUrl(['category_id' => $OOobject->getId()]) . '"'; - } - - $label = self::formatLabel($OOobject); - - $icon = ''; - - return '' . $icon . ' ' . escape($label) . '' . $OOobject->getId() . ''; - } } diff --git a/src/Content/StructureElement.php b/src/Content/StructureElement.php index 833839e9df..5844088674 100644 --- a/src/Content/StructureElement.php +++ b/src/Content/StructureElement.php @@ -2,172 +2,45 @@ namespace Redaxo\Core\Content; -use AllowDynamicProperties; use Redaxo\Core\Base\InstanceListPoolTrait; use Redaxo\Core\Base\InstancePoolTrait; -use Redaxo\Core\Core; -use Redaxo\Core\Database\Sql; use Redaxo\Core\Exception\LogicException; +use Redaxo\Core\Exception\RuntimeException; use Redaxo\Core\Filesystem\File; use Redaxo\Core\Filesystem\Path; use Redaxo\Core\Filesystem\Url; use Redaxo\Core\Language\Language; +use function array_key_exists; use function in_array; +use function sprintf; /** - * Object Oriented Framework: Basisklasse für die Strukturkomponenten. + * Basisklasse für die Strukturkomponenten. */ -#[AllowDynamicProperties] abstract class StructureElement { use InstanceListPoolTrait; use InstancePoolTrait; - /** @var int */ - protected $id = 0; - - /** @var int */ - protected $parent_id = 0; - - /** @var int */ - protected $clang_id = 0; - - /** @var string */ - protected $name = ''; - - /** @var string */ - protected $catname = ''; - - protected ?string $template = null; - - /** @var string */ - protected $path = ''; - - /** @var int */ - protected $priority = 0; - - /** @var int */ - protected $catpriority = 0; - - /** @var bool */ - protected $startarticle = false; - - /** @var int */ - protected $status = 0; - - /** @var int */ - protected $updatedate = 0; - - /** @var int */ - protected $createdate = 0; - - /** @var string */ - protected $updateuser = ''; - - /** @var string */ - protected $createuser = ''; - - /** @var list|null */ - protected static $classVars; - - protected function __construct(array $params) - { - foreach (self::getClassVars() as $var) { - if (!isset($params[$var])) { - continue; - } - - if (in_array($var, ['id', 'parent_id', 'clang_id', 'priority', 'catpriority', 'status', 'createdate', 'updatedate'], true)) { - $this->$var = (int) $params[$var]; - } elseif ('startarticle' === $var) { - $this->$var = (bool) $params[$var]; - } else { - $this->$var = $params[$var]; - } - } - } - - /** - * Returns Object Value. - * - * @param string $value - * @return string|int|null - * @psalm-taint-source input - */ - public function getValue($value) - { - // damit alte rex_article felder wie teaser, online_from etc - // noch funktionieren - // gleicher BC code nochmals in article::getValue - foreach (['', 'art_', 'cat_'] as $prefix) { - $val = $prefix . $value; - if (isset($this->$val)) { - return $this->$val; - } - } - return null; - } - - /** - * @param string $value - * - * @return bool - */ - protected static function _hasValue($value, array $prefixes = []) - { - $values = self::getClassVars(); - - if (in_array($value, $values)) { - return true; - } - - foreach ($prefixes as $prefix) { - if (in_array($prefix . $value, $values)) { - return true; - } - } - - return false; - } - - /** - * Returns an Array containing article field names. - * - * @return list - */ - public static function getClassVars() - { - if (empty(self::$classVars)) { - self::$classVars = []; - - $startId = Article::getSiteStartArticleId(); - $file = Path::coreCache('structure/' . $startId . '.1.article'); - if (!Core::isBackend() && is_file($file)) { - // da getClassVars() eine statische Methode ist, können wir hier nicht mit $this->getId() arbeiten! - $genVars = File::getCache($file, []); - unset($genVars['last_update_stamp']); - foreach ($genVars as $name => $value) { - self::$classVars[] = (string) $name; - } - } else { - // Im Backend die Spalten aus der DB auslesen / via EP holen - $sql = Sql::factory(); - $sql->setQuery('SELECT * FROM ' . Core::getTablePrefix() . 'article LIMIT 0'); - foreach ($sql->getFieldnames() as $field) { - self::$classVars[] = $field; - } - } - } - - return self::$classVars; - } - - /** @return void */ - public static function resetClassVars() - { - self::$classVars = null; - } + /** Prefix used for meta-info fields of this element type (`art_` or `cat_`). */ + abstract protected string $metaInfoPrefix { get; } + + protected function __construct( + public readonly int $id, + public readonly int $clangId, + public readonly string $name, + public readonly int $priority, + /** @var list */ + public readonly array $path, + public readonly int $status, + public readonly int $createDate, + public readonly int $updateDate, + public readonly string $createUser, + public readonly string $updateUser, + /** @var array */ + final protected readonly array $additionalData, + ) {} /** * Return a StructureElement object based on an id. @@ -188,7 +61,7 @@ public static function get(int $id, ?int $clang = null): ?static $clang = Language::getCurrentId(); } - return static::getInstance([$id, $clang], static function () use ($id, $clang) { + return static::getInstance([$id, $clang], static function () use ($id, $clang): ?static { $articlePath = Path::coreCache('structure/' . $id . '.' . $clang . '.article'); // load metadata from cache @@ -205,17 +78,31 @@ public static function get(int $id, ?int $clang = null): ?static return null; } - // don't allow to retrieve non-categories (startarticle=0) as Category - if (!$metadata['startarticle'] && (Category::class === static::class || is_subclass_of(static::class, Category::class))) { - return null; - } - - return new static($metadata); + /** @var array $metadata */ + return static::fromCache($metadata); }); } + /** + * Returns the element for the given id like {@see get()}, but throws when it does not exist. + * + * @throws RuntimeException if no element exists for the given id and clang + */ + public static function require(int $id, ?int $clang = null): static + { + return static::get($id, $clang) + ?? throw new RuntimeException(sprintf('Required %s with id "%d" and clang "%s" does not exist.', static::class, $id, $clang ?? Language::getCurrentId())); + } + + /** + * Builds an instance from the cache row. + * + * @param array $data + */ + abstract protected static function fromCache(array $data): ?static; + /** @return list */ - protected static function getChildElements(int $parentId, string $listType, bool $ignoreOfflines = false, ?int $clang = null): array + final protected static function getChildElements(int $parentId, string $listType, bool $ignoreOfflines = false, ?int $clang = null): array { // for $parentId=0 root elements will be returned, so abort here for $parentId<0 only if (0 > $parentId) { @@ -252,208 +139,88 @@ static function () use ($parentId, $listType) { ); } - /** - * Returns the clang of the category. - * - * @return int - */ - public function getClangId() - { - return $this->clang_id; - } - - /** - * Returns a url for linking to this article. - * - * @return string - */ - public function getUrl(array $params = []) + /** @psalm-taint-source input */ + public function getValue(string $key): string|int|null { - return Url::article($this->getId(), $this->getClangId(), $params); + $key = strtolower($key); + + return match ($key) { + 'id' => $this->id, + 'clang_id' => $this->clangId, + 'name' => $this->name, + 'priority' => $this->priority, + 'path' => $this->path ? '|' . implode('|', $this->path) . '|' : '|', + 'status' => $this->status, + 'createdate' => $this->createDate, + 'updatedate' => $this->updateDate, + 'createuser' => $this->createUser, + 'updateuser' => $this->updateUser, + default => $this->additionalData[$key] ?? $this->additionalData[$this->metaInfoPrefix . $key] ?? null, + }; } - /** - * Returns the id of the article. - * - * @return int - */ - public function getId() + /** Checks whether this element has a value for the given key. */ + public function hasValue(string $key): bool { - return $this->id; + $key = strtolower($key); + + return in_array($key, [ + 'id', 'clang_id', 'name', 'priority', 'path', 'status', + 'createdate', 'updatedate', 'createuser', 'updateuser', + ], true) + || array_key_exists($key, $this->additionalData) + || array_key_exists($this->metaInfoPrefix . $key, $this->additionalData); } - /** - * Returns the parent_id of the article. - * - * @return int - */ - public function getParentId() + /** Returns a url for linking to this article. */ + public function getUrl(array $params = []): string { - return $this->parent_id; + return Url::article($this->id, $this->clangId, $params); } /** - * Returns the path of the category/article. - * - * @return string + * Returns the next category upwards in the structure tree + * (the containing category for articles, the parent category for categories). */ - abstract public function getPath(); + abstract protected function getParent(): ?Category; - /** - * Returns the path ids of the category/article as an array. - * - * @return list - */ - public function getPathAsArray() + /** Returns true if article is online. */ + public function isOnline(): bool { - $path = explode('|', $this->getPath()); - return array_values(array_map('intval', array_filter($path))); + return 1 === $this->status; } - /** - * Returns the parent category. - * - * @return static|null - */ - abstract public function getParent(); - - /** - * Returns the name of the article. - * - * @return string - * @psalm-taint-source input - */ - public function getName() - { - return $this->name; - } - - /** - * Returns the article priority. - * - * @return int - */ - public function getPriority() - { - return $this->priority; - } - - /** - * Returns the last update user. - * - * @return string - */ - public function getUpdateUser() - { - return $this->updateuser; - } - - /** - * Returns the last update date. - * - * @return int - */ - public function getUpdateDate() - { - return $this->updatedate; - } - - /** - * Returns the creator. - * - * @return string - */ - public function getCreateUser() - { - return $this->createuser; - } - - /** - * Returns the creation date. - * - * @return int - */ - public function getCreateDate() - { - return $this->createdate; - } - - /** - * Returns true if article is online. - * - * @return bool - */ - public function isOnline() - { - return 1 == $this->status; - } - - public function getTemplateKey(): ?string - { - return $this->template; - } - - /** - * Returns true if article has a template. - * - * @return bool - */ - public function hasTemplate() - { - return null !== $this->template; - } - - /** - * Returns whether the element is permitted. - * - * @return bool - */ - abstract public function isPermitted(); + /** Returns whether the element is permitted. */ + abstract public function isPermitted(): bool; /** - * Get an array of all parentCategories. - * Returns an array of StructureElement objects. + * Get an array of all parent categories. * * @return list */ - public function getParentTree() + public function getParentTree(): array { $return = []; - if ($this->path) { - if ($this->isStartArticle()) { - $explode = explode('|', $this->path . $this->id . '|'); - } else { - $explode = explode('|', $this->path); - } - - foreach ($explode as $var) { - if ('' != $var) { - $cat = Category::get((int) $var, $this->clang_id); - if (!$cat) { - throw new LogicException('No category found with id=' . $var . ' and clang=' . $this->clang_id . '.'); - } - $return[] = $cat; - } + foreach ($this->path as $id) { + $cat = Category::get($id, $this->clangId); + if (!$cat) { + throw new LogicException('No category found with id=' . $id . ' and clang=' . $this->clangId . '.'); } + $return[] = $cat; } return $return; } - /** - * Checks if $anObj is in the parent tree of the object. - * - * @return bool - */ - public function inParentTree(self $anObj) + /** Checks if $anObj is in the parent tree of the object. */ + public function inParentTree(self $anObj): bool { - $tree = $this->getParentTree(); - return in_array($anObj, $tree); + return in_array($anObj, $this->getParentTree(), true); } /** - * Returns the closest element from parent tree (including itself) where the callback returns true. + * Returns the closest element (this element or any parent category) where the callback returns true. * * @param callable(self):bool $callback */ @@ -463,17 +230,11 @@ public function getClosest(callable $callback): ?self return $this; } - $parent = $this->getParent(); - - return $parent ? $parent->getClosest($callback) : null; + return $this->getParent()?->getClosest($callback); } - /** - * Returns the value from this element or from the closest parent where the value is set. - * - * @return string|int|null - */ - public function getClosestValue(string $key) + /** Returns the value from this element or from the closest parent category where the value is set. */ + public function getClosestValue(string $key): string|int|null { $value = $this->getValue($key); @@ -481,12 +242,10 @@ public function getClosestValue(string $key) return $value; } - $parent = $this->getParent(); - - return $parent ? $parent->getClosestValue($key) : null; + return $this->getParent()?->getClosestValue($key); } - /** Returns true if this element and all parents are online. */ + /** Returns true if this element and all parent categories are online. */ public function isOnlineIncludingParents(): bool { if (!$this->isOnline()) { @@ -497,34 +256,4 @@ public function isOnlineIncludingParents(): bool return !$parent || $parent->isOnlineIncludingParents(); } - - /** - * Returns true if this Article is the Startpage for the category. - * - * @return bool - */ - public function isStartArticle() - { - return $this->startarticle; - } - - /** - * Returns true if this Article is the Startpage for the entire site. - * - * @return bool - */ - public function isSiteStartArticle() - { - return $this->id == Article::getSiteStartArticleId(); - } - - /** - * Returns true if this Article is the not found article. - * - * @return bool - */ - public function isNotFoundArticle() - { - return $this->id == Article::getNotfoundArticleId(); - } } diff --git a/src/Content/StructurePermission.php b/src/Content/StructurePermission.php index df8cebcfbf..6e17aa3e44 100644 --- a/src/Content/StructurePermission.php +++ b/src/Content/StructurePermission.php @@ -12,14 +12,20 @@ /** @extends ComplexPermission */ final class StructurePermission extends ComplexPermission { - public function hasCategoryPerm(int $categoryId): bool + public function hasCategoryPerm(?int $categoryId): bool { - if ($this->hasAll() || in_array($categoryId, $this->perms, true)) { + if ($this->hasAll()) { + return true; + } + if (null === $categoryId) { + return false; + } + if (in_array($categoryId, $this->perms, true)) { return true; } if ($c = Category::get($categoryId)) { $perms = $this->perms; - return array_any($c->getPathAsArray(), static fn (int $k) => in_array($k, $perms, true)); + return array_any($c->path, static fn (int $k) => in_array($k, $perms, true)); } return false; } @@ -56,16 +62,16 @@ public function getMountpointCategories(): array } $categories[] = $category; - $parents[$category->getParentId()] = true; + $parents[$category->parentId ?? 0] = true; } if (count($parents) <= 1) { usort($categories, static function (Category $a, Category $b) { - return $a->getPriority() <=> $b->getPriority(); + return $a->priority <=> $b->priority; }); } else { usort($categories, static function (Category $a, Category $b) { - return strcasecmp($a->getName(), $b->getName()); + return strcasecmp($a->name, $b->name); }); } diff --git a/src/Form/Select/CategorySelect.php b/src/Form/Select/CategorySelect.php index 34f3a4fbfd..7e6e74735b 100644 --- a/src/Form/Select/CategorySelect.php +++ b/src/Form/Select/CategorySelect.php @@ -88,21 +88,18 @@ protected function addCatOptions() /** @return void */ protected function addCatOption(Category $cat, $group = null) { - if (!$this->checkPerms || Core::requireUser()->getComplexPerm('structure')->hasCategoryPerm($cat->getId()) + if (!$this->checkPerms || Core::requireUser()->getComplexPerm('structure')->hasCategoryPerm($cat->id) ) { - $cid = $cat->getId(); - $cname = $cat->getName() . ' [' . $cid . ']'; + $cid = $cat->id; + $cname = $cat->name . ' [' . $cid . ']'; if (null === $group) { - $group = $cat->getParentId(); + $group = $cat->parentId ?? 0; } $this->addOption($cname, $cid, $cid, $group); - $childs = $cat->getChildren($this->ignoreOfflines); - if (is_array($childs)) { - foreach ($childs as $child) { - $this->addCatOption($child); - } + foreach ($cat->getChildren($this->ignoreOfflines) as $child) { + $this->addCatOption($child); } } } diff --git a/src/MediaPool/MediaPool.php b/src/MediaPool/MediaPool.php index a6a849c08e..52c4a4f894 100644 --- a/src/MediaPool/MediaPool.php +++ b/src/MediaPool/MediaPool.php @@ -91,8 +91,8 @@ public static function mediaIsInUse(string $filename): string|false foreach ($res as $artArr) { $aid = (int) $artArr['article_id']; $clang = (int) $artArr['clang_id']; - $ooa = Article::get($aid, $clang); - $name = ($ooa) ? $ooa->getName() : ''; + $article = Article::get($aid, $clang); + $name = $article ? $article->name : ''; $warning[0] .= '
  • ' . $name . '
  • '; } $warning[0] .= ''; diff --git a/src/MetaInfo/Handler/ArticleHandler.php b/src/MetaInfo/Handler/ArticleHandler.php index 50430d79d0..124a3f5392 100644 --- a/src/MetaInfo/Handler/ArticleHandler.php +++ b/src/MetaInfo/Handler/ArticleHandler.php @@ -54,16 +54,14 @@ protected function buildFilterCondition(array $params) if (!empty($params['id'])) { $s = ''; - $OOArt = Article::get($params['id'], $params['clang']); + $OOArt = Article::require($params['id'], $params['clang']); // Alle Metafelder des Pfades sind erlaubt - foreach ($OOArt->getPathAsArray() as $pathElement) { - if ('' != $pathElement) { - $s .= ' OR FIND_IN_SET(' . $pathElement . ', `p`.`restrictions`)'; - } + foreach ($OOArt->path as $pathElement) { + $s .= ' OR FIND_IN_SET(' . $pathElement . ', `p`.`restrictions`)'; } - $t = ' OR FIND_IN_SET(' . Sql::factory()->escape($OOArt->getTemplateKey() ?? '') . ', `p`.`templates`)'; + $t = ' OR FIND_IN_SET(' . Sql::factory()->escape($OOArt->templateKey ?? '') . ', `p`.`templates`)'; $restrictionsCondition = 'AND (`p`.`restrictions` = "" OR `p`.`restrictions` IS NULL ' . $s . ') AND (`p`.`templates` = "" OR `p`.`templates` IS NULL ' . $t . ')'; } @@ -83,7 +81,7 @@ public function getForm(array $params) $params['activeItem'] = $params['article']; // Hier die category_id setzen, damit beim klick auf den REX_LINK_BUTTON der Medienpool in der aktuellen Kategorie startet - $params['activeItem']->setValue('category_id', $OOArt->getCategoryId()); + $params['activeItem']->setValue('category_id', $OOArt->categoryId ?? 0); return parent::renderFormAndSave(self::PREFIX, $params); } diff --git a/src/MetaInfo/Handler/CategoryHandler.php b/src/MetaInfo/Handler/CategoryHandler.php index 874ee45de2..45c2f64b7e 100644 --- a/src/MetaInfo/Handler/CategoryHandler.php +++ b/src/MetaInfo/Handler/CategoryHandler.php @@ -64,13 +64,11 @@ protected function buildFilterCondition(array $params) $s = ''; if (!empty($params['id'])) { - $OOCat = Category::get($params['id'], $params['clang']); + $OOCat = Category::require($params['id'], $params['clang']); // Alle Metafelder des Pfades sind erlaubt - foreach ($OOCat->getPathAsArray() as $pathElement) { - if ('' != $pathElement) { - $s .= ' OR FIND_IN_SET(' . $pathElement . ', `p`.`restrictions`)'; - } + foreach ($OOCat->path as $pathElement) { + $s .= ' OR FIND_IN_SET(' . $pathElement . ', `p`.`restrictions`)'; } // Auch die Kategorie selbst kann Metafelder haben diff --git a/src/RexVar/LinkListVar.php b/src/RexVar/LinkListVar.php index dab4c307ca..a9627753cf 100644 --- a/src/RexVar/LinkListVar.php +++ b/src/RexVar/LinkListVar.php @@ -20,7 +20,7 @@ */ public static function getWidget($id, $name, $value, array $args = []) { - $category = Category::getCurrent() ? Category::getCurrent()->getId() : 0; // Aktuelle Kategorie vorauswählen + $category = Category::getCurrent()->id ?? 0; // Aktuelle Kategorie vorauswählen // Falls ein Kategorie-Parameter angegeben wurde, die Linkmap in dieser Kategorie öffnen if (isset($args['category'])) { @@ -36,7 +36,7 @@ public static function getWidget($id, $name, $value, array $args = []) continue; } if ($article = Article::get((int) $link)) { - $options .= ''; + $options .= ''; } } diff --git a/src/RexVar/LinkVar.php b/src/RexVar/LinkVar.php index b386585ad5..980f18eea8 100644 --- a/src/RexVar/LinkVar.php +++ b/src/RexVar/LinkVar.php @@ -22,12 +22,12 @@ public static function getWidget($id, $name, $value, array $args = []) { $artName = ''; $art = $value ? Article::get($value) : null; - $category = Category::getCurrent() ? Category::getCurrent()->getId() : 0; // Aktuelle Kategorie vorauswählen + $category = Category::getCurrent()->id ?? 0; // Aktuelle Kategorie vorauswählen // Falls ein Artikel vorausgewählt ist, dessen Namen anzeigen und beim Öffnen der Linkmap dessen Kategorie anzeigen if ($art instanceof Article) { - $artName = trim(sprintf('%s [%s]', $art->getName(), $art->getId())); - $category = $art->getCategoryId(); + $artName = trim(sprintf('%s [%s]', $art->name, $art->id)); + $category = $art->categoryId ?? 0; } // Falls ein Kategorie-Parameter angegeben wurde, die Linkmap in dieser Kategorie öffnen diff --git a/src/View/Navigation.php b/src/View/Navigation.php index 3a7068d529..f6855fe163 100644 --- a/src/View/Navigation.php +++ b/src/View/Navigation.php @@ -167,8 +167,8 @@ public function getBreadcrumb($startPageLabel, $includeCurrent = false, $categor } } - $cat = Category::get($pathItem); - $link = $this->getBreadcrumbLinkTag($cat, escape($cat->getName()), [ + $cat = Category::require($pathItem); + $link = $this->getBreadcrumbLinkTag($cat, escape($cat->name), [ 'href' => $cat->getUrl(), ], $i); $lis[] = $this->getBreadcrumbListItemTag($link, [ @@ -180,13 +180,13 @@ public function getBreadcrumb($startPageLabel, $includeCurrent = false, $categor if ($includeCurrent) { if ($art = Article::get($this->currentArticleId)) { if (!$art->isStartArticle()) { - $lis[] = $this->getBreadcrumbListItemTag(escape($art->getName()), [ + $lis[] = $this->getBreadcrumbListItemTag(escape($art->name), [ 'class' => 'rex-lvl' . $i, ], $i); } } else { - $cat = Category::get($this->currentArticleId); - $lis[] = $this->getBreadcrumbListItemTag(escape($cat->getName()), [ + $cat = Category::require($this->currentArticleId); + $lis[] = $this->getBreadcrumbListItemTag(escape($cat->name), [ 'class' => 'rex-lvl' . $i, ], $i); } @@ -265,15 +265,9 @@ private function _setActivePath() { $articleId = Article::getCurrentId(); if ($OOArt = Article::get($articleId)) { - $path = trim($OOArt->getPath(), '|'); - - $this->path = []; - if ('' != $path) { - $this->path = array_map(intval(...), explode('|', $path)); - } - + $this->path = $OOArt->path; $this->currentArticleId = $articleId; - $this->currentCategoryId = $OOArt->getCategoryId(); + $this->currentCategoryId = $OOArt->categoryId ?? 0; return true; } @@ -394,14 +388,14 @@ protected function _getNavigation($categoryId, $depth = 1) $li['class'] = []; $a['class'] = []; $a['href'] = [$nav->getUrl()]; - $aContent = escape($nav->getName()); + $aContent = escape($nav->name); if ($this->checkFilter($nav, $depth) && $this->checkCallbacks($nav, $depth, $li, $a, $aContent)) { - $li['class'][] = 'rex-article-' . $nav->getId(); + $li['class'][] = 'rex-article-' . $nav->id; // classes abhaengig vom pfad - if ($nav->getId() == $this->currentCategoryId) { + if ($nav->id == $this->currentCategoryId) { $li['class'][] = 'rex-current'; $a['class'][] = 'rex-current'; - } elseif (in_array($nav->getId(), $this->path)) { + } elseif (in_array($nav->id, $this->path)) { $li['class'][] = 'rex-active'; $a['class'][] = 'rex-active'; } else { @@ -418,10 +412,10 @@ protected function _getNavigation($categoryId, $depth = 1) ++$depth; if ( - ($this->open || $nav->getId() == $this->currentCategoryId || in_array($nav->getId(), $this->path)) + ($this->open || $nav->id == $this->currentCategoryId || in_array($nav->id, $this->path)) && ($this->depth >= $depth || $this->depth < 0) ) { - $link .= "\n" . $this->_getNavigation($nav->getId(), $depth); + $link .= "\n" . $this->_getNavigation($nav->id, $depth); } --$depth; $lis[] = $this->getListItemTag($nav, $link, $li, $depth); diff --git a/src/View/View.php b/src/View/View.php index 9a5eeebb6f..f84c32b0c0 100644 --- a/src/View/View.php +++ b/src/View/View.php @@ -6,6 +6,7 @@ use Redaxo\Core\Backend\Navigation; use Redaxo\Core\Backend\Page; use Redaxo\Core\Content\Article; +use Redaxo\Core\Content\Category; use Redaxo\Core\Core; use Redaxo\Core\Exception\InvalidArgumentException; use Redaxo\Core\ExtensionPoint\Extension; @@ -247,11 +248,11 @@ public static function structureBreadcrumb(int $categoryId, int $articleId, int $tree[] = $object; } foreach ($tree as $parent) { - $id = $parent->getId(); + $id = $parent->id; if (Core::requireUser()->getComplexPerm('structure')->hasCategoryPerm($id)) { $n = []; - $n['title'] = str_replace(' ', ' ', escape($parent->getName())); - if ($parent->isStartArticle()) { + $n['title'] = str_replace(' ', ' ', escape($parent->name)); + if ($parent instanceof Category) { $n['href'] = Url::backendPage('structure', ['category_id' => $id, 'clang' => $clang]); } $navigation[] = $n; diff --git a/tests/Content/ArticleContentTest.php b/tests/Content/ArticleContentTest.php index 198894800f..bfd113be20 100644 --- a/tests/Content/ArticleContentTest.php +++ b/tests/Content/ArticleContentTest.php @@ -13,7 +13,6 @@ use Redaxo\Core\Filesystem\File; use Redaxo\Core\Filesystem\Finder; use Redaxo\Core\Filesystem\Path; -use ReflectionClass; use ReflectionProperty; /** @internal */ @@ -44,15 +43,6 @@ protected function setUp(): void 'art_foo' => 'teststring', ]); - - // generate classVars and add test column - Article::getClassVars(); - $class = new ReflectionClass(Article::class); - /** @psalm-suppress MixedArgument */ - $class->setStaticPropertyValue('classVars', array_merge( - $class->getStaticPropertyValue('classVars'), - ['art_foo'], - )); } protected function tearDown(): void @@ -64,10 +54,6 @@ protected function tearDown(): void ->ignoreSystemStuff(false); Dir::deleteIterator($finder); - // reset static properties - $class = new ReflectionClass(Article::class); - $class->setStaticPropertyValue('classVars', null); - Article::clearInstancePool(); } diff --git a/tests/Content/ArticleTest.php b/tests/Content/ArticleTest.php index 1ecd9462f5..4b63c6c947 100644 --- a/tests/Content/ArticleTest.php +++ b/tests/Content/ArticleTest.php @@ -2,40 +2,30 @@ namespace Redaxo\Core\Tests\Content; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Redaxo\Core\Content\Article; +use Redaxo\Core\Content\Category; +use Redaxo\Core\Content\StructureElement; use ReflectionClass; +use ReflectionProperty; /** @internal */ final class ArticleTest extends TestCase { - protected function setUp(): void - { - // generate classVars and add test column - Article::getClassVars(); - $class = new ReflectionClass(Article::class); - /** @psalm-suppress MixedArgument */ - $class->setStaticPropertyValue('classVars', array_merge( - $class->getStaticPropertyValue('classVars'), - ['art_foo'], - )); - } + private static int $nextId = 20000; - protected function tearDown(): void + public static function tearDownAfterClass(): void { - // reset static properties - $class = new ReflectionClass(Article::class); - $class->setStaticPropertyValue('classVars', null); - Article::clearInstancePool(); + // Categories created here are left in the pool — clearing them would break + // CategoryTest, which already populated its own pool entries via data providers + // and runs after ArticleTest alphabetically. } public function testHasValue(): void { - $instance = $this->createArticleWithoutConstructor(); - - /** @psalm-suppress UndefinedPropertyAssignment */ - $instance->art_foo = 'teststring'; // @phpstan-ignore-line + $instance = $this->createArticleWithAdditionalData(['art_foo' => 'teststring']); self::assertTrue($instance->hasValue('foo')); self::assertTrue($instance->hasValue('art_foo')); @@ -46,10 +36,7 @@ public function testHasValue(): void public function testGetValue(): void { - $instance = $this->createArticleWithoutConstructor(); - - /** @psalm-suppress UndefinedPropertyAssignment */ - $instance->art_foo = 'teststring'; // @phpstan-ignore-line + $instance = $this->createArticleWithAdditionalData(['art_foo' => 'teststring']); self::assertEquals('teststring', $instance->getValue('foo')); self::assertEquals('teststring', $instance->getValue('art_foo')); @@ -58,8 +45,193 @@ public function testGetValue(): void self::assertNull($instance->getValue('art_bar')); } - private function createArticleWithoutConstructor(): Article + /** @param callable(StructureElement):bool $callback */ + #[DataProvider('dataGetClosest')] + public function testGetClosest(?StructureElement $expected, Article $article, callable $callback): void + { + self::assertSame($expected, $article->getClosest($callback)); + } + + /** @return iterable */ + public static function dataGetClosest(): iterable + { + $statusCallback = static fn (StructureElement $el): bool => 1 === $el->getValue('status'); + + // online article in online tree → article itself + [$_, $_, $lev3] = self::createCategories(['status' => 1], ['status' => 1], ['status' => 1]); + $article = self::createArticle($lev3, ['status' => 1]); + yield [$article, $article, $statusCallback]; + + // offline article, online container category → category + [$_, $_, $lev3] = self::createCategories(['status' => 1], ['status' => 1], ['status' => 1]); + $article = self::createArticle($lev3, ['status' => 0]); + yield [$lev3, $article, $statusCallback]; + + // offline article + offline container, online grandparent → grandparent + [$lev1, $_, $lev3] = self::createCategories(['status' => 1], ['status' => 0], ['status' => 0]); + $article = self::createArticle($lev3, ['status' => 0]); + yield [$lev1, $article, $statusCallback]; + + // nothing matches → null + [$_, $_, $lev3] = self::createCategories(['status' => 0], ['status' => 0], ['status' => 0]); + $article = self::createArticle($lev3, ['status' => 0]); + yield [null, $article, $statusCallback]; + + // meta-info value found via metaInfoPrefix lookup: art_foo on article, cat_foo on cats + $fooCallback = static fn (StructureElement $el): bool => $el->getValue('foo') > 3; + [$lev1, $_, $lev3] = self::createCategories(['cat_foo' => 4], [], ['cat_foo' => 2]); + $article = self::createArticle($lev3, ['art_foo' => 1]); + yield [$lev1, $article, $fooCallback]; + + // article's own art_foo matches first + [$_, $_, $lev3] = self::createCategories(['cat_foo' => 4], [], ['cat_foo' => 2]); + $article = self::createArticle($lev3, ['art_foo' => 5]); + yield [$article, $article, $fooCallback]; + } + + #[DataProvider('dataGetClosestValue')] + public function testGetClosestValue(string|int|null $expectedValue, Article $article): void + { + self::assertSame($expectedValue, $article->getClosestValue('foo')); + } + + /** @return iterable */ + public static function dataGetClosestValue(): iterable + { + [$_, $_, $lev3] = self::createCategories([], [], []); + yield [null, self::createArticle($lev3, [])]; + + // article's own art_foo wins over everything else in the cat tree + [$_, $_, $lev3] = self::createCategories(['cat_foo' => 'baz'], ['cat_foo' => 'bar'], ['cat_foo' => 'foo']); + yield ['from-article', self::createArticle($lev3, ['art_foo' => 'from-article'])]; + + // no art_foo on article — falls through to cat_foo on containing cat + [$_, $_, $lev3] = self::createCategories([], [], ['cat_foo' => 'foo']); + yield ['foo', self::createArticle($lev3, [])]; + + // direct cat wins over grandparents + [$_, $_, $lev3] = self::createCategories([], ['cat_foo' => 'bar'], ['cat_foo' => 'foo']); + yield ['foo', self::createArticle($lev3, [])]; + + // walks up to nearest cat with value + [$_, $_, $lev3] = self::createCategories([], ['cat_foo' => 'bar'], []); + yield ['bar', self::createArticle($lev3, [])]; + + [$_, $_, $lev3] = self::createCategories(['cat_foo' => 'baz'], [], []); + yield ['baz', self::createArticle($lev3, [])]; + + [$_, $_, $lev3] = self::createCategories([], ['cat_foo' => 0], []); + yield [0, self::createArticle($lev3, [])]; + + // start-articles fall through to their parent category — own cat (lev3) is skipped + [$_, $_, $lev3] = self::createCategories(['cat_foo' => 'baz'], ['cat_foo' => 'bar'], ['cat_foo' => 'foo']); + yield ['bar', self::createArticle($lev3, [], startArticle: true)]; + } + + #[DataProvider('dataIsOnlineIncludingParents')] + public function testIsOnlineIncludingParents(bool $expected, Article $article): void + { + self::assertSame($expected, $article->isOnlineIncludingParents()); + } + + /** @return iterable */ + public static function dataIsOnlineIncludingParents(): iterable + { + [$_, $_, $lev3] = self::createCategories(['status' => 1], ['status' => 1], ['status' => 1]); + yield [true, self::createArticle($lev3, ['status' => 1])]; + + [$_, $_, $lev3] = self::createCategories(['status' => 1], ['status' => 1], ['status' => 1]); + yield [false, self::createArticle($lev3, ['status' => 0])]; + + [$_, $_, $lev3] = self::createCategories(['status' => 1], ['status' => 1], ['status' => 0]); + yield [false, self::createArticle($lev3, ['status' => 1])]; + + [$_, $_, $lev3] = self::createCategories(['status' => 0], ['status' => 1], ['status' => 1]); + yield [false, self::createArticle($lev3, ['status' => 1])]; + + // start-article in an offline parent category + [$_, $_, $lev3] = self::createCategories(['status' => 0], ['status' => 1], ['status' => 1]); + yield [false, self::createArticle($lev3, ['status' => 1], startArticle: true)]; + } + + /** @param array $additionalData */ + private function createArticleWithAdditionalData(array $additionalData): Article + { + $reflectionClass = new ReflectionClass(Article::class); + $article = $reflectionClass->newInstanceWithoutConstructor(); + + $reflectionClass->getProperty('additionalData')->setValue($article, $additionalData); + + return $article; + } + + /** + * @param array $lev1Params + * @param array $lev2Params + * @param array $lev3Params + * @return array{Category, Category, Category} + */ + private static function createCategories(array $lev1Params, array $lev2Params, array $lev3Params): array + { + $lev1 = self::createCategory(null, $lev1Params); + $lev2 = self::createCategory($lev1, $lev2Params); + $lev3 = self::createCategory($lev2, $lev3Params); + + return [$lev1, $lev2, $lev3]; + } + + /** @param array $params */ + private static function createCategory(?Category $parent, array $params): Category + { + $id = self::$nextId++; + $status = isset($params['status']) ? (int) $params['status'] : 1; + unset($params['status']); + + $reflectionClass = new ReflectionClass(Category::class); + $category = $reflectionClass->newInstanceWithoutConstructor(); + + $reflectionClass->getProperty('id')->setValue($category, $id); + $reflectionClass->getProperty('parentId')->setValue($category, $parent?->id); + $reflectionClass->getProperty('clangId')->setValue($category, 1); + $reflectionClass->getProperty('status')->setValue($category, $status); + $reflectionClass->getProperty('path')->setValue($category, []); + $reflectionClass->getProperty('additionalData')->setValue($category, $params); + + self::registerInstance(Category::class, $id, $category); + + return $category; + } + + /** @param array $params */ + private static function createArticle(Category $category, array $params, bool $startArticle = false): Article + { + $id = self::$nextId++; + $status = isset($params['status']) ? (int) $params['status'] : 1; + unset($params['status']); + + $reflectionClass = new ReflectionClass(Article::class); + $article = $reflectionClass->newInstanceWithoutConstructor(); + + $reflectionClass->getProperty('id')->setValue($article, $id); + $reflectionClass->getProperty('clangId')->setValue($article, 1); + $reflectionClass->getProperty('status')->setValue($article, $status); + $reflectionClass->getProperty('path')->setValue($article, []); + $reflectionClass->getProperty('categoryId')->setValue($article, $category->id); + $reflectionClass->getProperty('startArticle')->setValue($article, $startArticle); + $reflectionClass->getProperty('additionalData')->setValue($article, $params); + + self::registerInstance(Article::class, $id, $article); + + return $article; + } + + /** @param class-string $class */ + private static function registerInstance(string $class, int $id, StructureElement $instance): void { - return new ReflectionClass(Article::class)->newInstanceWithoutConstructor(); + $instancesProperty = new ReflectionProperty(StructureElement::class, 'instances'); + /** @var array> $instances */ + $instances = $instancesProperty->getValue(); + $instances[$class][$id . '###1'] = $instance; + $instancesProperty->setValue(null, $instances); } } diff --git a/tests/Content/CategoryTest.php b/tests/Content/CategoryTest.php index 7bd8fb0450..f20fe2fb3f 100644 --- a/tests/Content/CategoryTest.php +++ b/tests/Content/CategoryTest.php @@ -4,40 +4,24 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use Redaxo\Core\Content\Article; use Redaxo\Core\Content\Category; +use Redaxo\Core\Content\StructureElement; use ReflectionClass; +use ReflectionProperty; /** @internal */ final class CategoryTest extends TestCase { - protected function setUp(): void - { - // generate classVars and add test column - Category::getClassVars(); - $class = new ReflectionClass(Category::class); - /** @psalm-suppress MixedArgument */ - $class->setStaticPropertyValue('classVars', array_merge( - $class->getStaticPropertyValue('classVars'), - ['cat_foo'], - )); - } + private static int $nextId = 10000; - protected function tearDown(): void + public static function tearDownAfterClass(): void { - // reset static properties - $class = new ReflectionClass(Article::class); - $class->setStaticPropertyValue('classVars', null); - Category::clearInstancePool(); } public function testHasValue(): void { - $instance = $this->createCategoryWithoutConstructor(); - - /** @psalm-suppress UndefinedPropertyAssignment */ - $instance->cat_foo = 'teststring'; // @phpstan-ignore-line + $instance = $this->createCategoryWithAdditionalData(['cat_foo' => 'teststring']); self::assertTrue($instance->hasValue('foo')); self::assertTrue($instance->hasValue('cat_foo')); @@ -48,10 +32,7 @@ public function testHasValue(): void public function testGetValue(): void { - $instance = $this->createCategoryWithoutConstructor(); - - /** @psalm-suppress UndefinedPropertyAssignment */ - $instance->cat_foo = 'teststring'; // @phpstan-ignore-line + $instance = $this->createCategoryWithAdditionalData(['cat_foo' => 'teststring']); self::assertEquals('teststring', $instance->getValue('foo')); self::assertEquals('teststring', $instance->getValue('cat_foo')); @@ -129,64 +110,78 @@ public function testGetClosest(?Category $expected, Category $category, callable /** @return iterable */ public static function dataGetClosest(): iterable { - $callback = static function (Category $category) { - return 1 === $category->getValue('status'); - }; + $statusCallback = static fn (Category $category): bool => 1 === $category->getValue('status'); [$lev1, $_, $lev3] = self::createCategories(['status' => 0], ['status' => 0], ['status' => 0]); - yield [null, $lev1, $callback]; - yield [null, $lev3, $callback]; + yield [null, $lev1, $statusCallback]; + yield [null, $lev3, $statusCallback]; [$lev1, $_, $lev3] = self::createCategories(['status' => 1], ['status' => 1], ['status' => 1]); - yield [$lev1, $lev1, $callback]; - yield [$lev3, $lev3, $callback]; + yield [$lev1, $lev1, $statusCallback]; + yield [$lev3, $lev3, $statusCallback]; [$_, $lev2, $lev3] = self::createCategories(['status' => 1], ['status' => 1], ['status' => 0]); - yield [$lev2, $lev3, $callback]; + yield [$lev2, $lev3, $statusCallback]; [$lev1, $_, $lev3] = self::createCategories(['status' => 1], ['status' => 0], ['status' => 0]); - yield [$lev1, $lev3, $callback]; + yield [$lev1, $lev3, $statusCallback]; - $callback = static function (Category $category) { - return $category->getValue('cat_foo') > 3; - }; + $fooCallback = static fn (Category $category): bool => $category->getValue('cat_foo') > 3; [$lev1, $_, $lev3] = self::createCategories(['cat_foo' => 4], [], ['cat_foo' => 2]); - yield [$lev1, $lev3, $callback]; + yield [$lev1, $lev3, $fooCallback]; } - private function createCategoryWithoutConstructor(): Category + /** @param array $additionalData */ + private function createCategoryWithAdditionalData(array $additionalData): Category { - return new ReflectionClass(Category::class)->newInstanceWithoutConstructor(); + $reflectionClass = new ReflectionClass(Category::class); + $category = $reflectionClass->newInstanceWithoutConstructor(); + + $reflectionClass->getProperty('additionalData')->setValue($category, $additionalData); + + return $category; } - /** @return array{Category, Category, Category} */ + /** + * @param array $lev1Params + * @param array $lev2Params + * @param array $lev3Params + * @return array{Category, Category, Category} + */ private static function createCategories(array $lev1Params, array $lev2Params, array $lev3Params): array { $lev1 = self::createCategory(null, $lev1Params); - $lev2 = self::createCategory($lev1, $lev2Params); - $lev3 = self::createCategory($lev2, $lev3Params); + $lev2 = self::createCategory($lev1->id, $lev2Params); + $lev3 = self::createCategory($lev2->id, $lev3Params); return [$lev1, $lev2, $lev3]; } - private static function createCategory(?Category $parent, array $params): Category + /** @param array $params */ + private static function createCategory(?int $parentId, array $params): Category { - return new class($parent, $params) extends Category { - public function __construct( - private ?Category $parent, - array $params, - ) { - foreach ($params as $key => $value) { - $this->$key = $value; - } - } - - public function getParent(): ?Category - { - /** @var static|null */ - return $this->parent; - } - }; + $id = self::$nextId++; + $status = isset($params['status']) ? (int) $params['status'] : 1; + unset($params['status']); + + $reflectionClass = new ReflectionClass(Category::class); + $category = $reflectionClass->newInstanceWithoutConstructor(); + + $reflectionClass->getProperty('id')->setValue($category, $id); + $reflectionClass->getProperty('parentId')->setValue($category, $parentId); + $reflectionClass->getProperty('clangId')->setValue($category, 1); + $reflectionClass->getProperty('status')->setValue($category, $status); + $reflectionClass->getProperty('path')->setValue($category, []); + $reflectionClass->getProperty('additionalData')->setValue($category, $params); + + // register in instance pool so Category::get($id, 1) returns this instance + $instancesProperty = new ReflectionProperty(StructureElement::class, 'instances'); + /** @var array> $instances */ + $instances = $instancesProperty->getValue(); + $instances[Category::class][$id . '###1'] = $category; + $instancesProperty->setValue(null, $instances); + + return $category; } }