diff --git a/core/database/migrations/2026_10_01_000000_add_parent_menuindex_index_to_site_content.php b/core/database/migrations/2026_10_01_000000_add_parent_menuindex_index_to_site_content.php new file mode 100644 index 0000000000..069724d8f7 --- /dev/null +++ b/core/database/migrations/2026_10_01_000000_add_parent_menuindex_index_to_site_content.php @@ -0,0 +1,33 @@ +index(['parent', 'menuindex'], \DB::getTablePrefix() . $table->getTable() . '_parent_menuindex'); + }); + } + + public function down() { + if (Schema::hasTable('site_content') && Schema::hasIndex('site_content', ['parent', 'menuindex'])) { + Schema::table('site_content', function (Blueprint $table) { + $table->dropIndex(\DB::getTablePrefix() . $table->getTable() . '_parent_menuindex'); + }); + } + } +} diff --git a/core/factory/settings.php b/core/factory/settings.php index 4eedb1f609..e1197f74c5 100644 --- a/core/factory/settings.php +++ b/core/factory/settings.php @@ -1,4 +1,10 @@ - 'My Evolution CMS Site', 'site_start' => 1, 'error_page' => 1, @@ -11,8 +17,8 @@ 'enable_filter' => 0, 'enable_at_syntax' => 0, 'minifyphp_incache' => 0, - 'rss_url_releases' => __('global.rss_url_releases_default', [], $factoryLocale), - 'rss_url_extras' => __('global.rss_url_extras_default', [], $factoryLocale), + 'rss_url_releases' => $known['rss_url_releases'] ?? __('global.rss_url_releases_default', [], $factoryLocale), + 'rss_url_extras' => $known['rss_url_extras'] ?? __('global.rss_url_extras_default', [], $factoryLocale), 'friendly_urls' => 0, 'friendly_url_prefix' => '', 'friendly_url_suffix' => '/', @@ -33,15 +39,15 @@ 'error_reporting' => '1', 'send_errormail' => '0', 'enable_bindings' => 1, - 'captcha_words' => __('global.captcha_words_default', [], $factoryLocale), + 'captcha_words' => $known['captcha_words'] ?? __('global.captcha_words_default', [], $factoryLocale), 'emailsender' => 'you@example.com', 'smtp_host' => 'smtp.example.com', 'smtp_port' => 25, 'smtp_username' => 'emailsender', - 'emailsubject' => __('global.emailsubject_default', [], $factoryLocale), - 'signupemail_message' => __('global.system_email_signup', [], $factoryLocale), - 'websignupemail_message' => __('global.system_email_websignup', [], $factoryLocale), - 'webpwdreminder_message' => __('global.system_email_webreminder', [], $factoryLocale), + 'emailsubject' => $known['emailsubject'] ?? __('global.emailsubject_default', [], $factoryLocale), + 'signupemail_message' => $known['signupemail_message'] ?? __('global.system_email_signup', [], $factoryLocale), + 'websignupemail_message' => $known['websignupemail_message'] ?? __('global.system_email_websignup', [], $factoryLocale), + 'webpwdreminder_message' => $known['webpwdreminder_message'] ?? __('global.system_email_webreminder', [], $factoryLocale), 'warning_visibility' => 1, 'tree_page_click' => 27, 'use_breadcrumbs' => 0, @@ -79,7 +85,7 @@ 'denyExtensionRename' => 0, 'showHiddenFiles' => 0, 'session_timeout' => 15, - 'site_unavailable_message' => __('global.siteunavailable_message_default', [], $factoryLocale), + 'site_unavailable_message' => $known['site_unavailable_message'] ?? __('global.siteunavailable_message_default', [], $factoryLocale), 'allow_eval' => 'with_scan', 'safe_functions_at_eval' => 'time,date,strtotime,strftime', 'use_udperms' => '1', diff --git a/core/src/Core.php b/core/src/Core.php index e360f00c3f..43ee5da00b 100644 --- a/core/src/Core.php +++ b/core/src/Core.php @@ -2928,16 +2928,16 @@ public function getDocumentObject($method, $identifier, $isPrepareResponse = fal $documentObject = null; if ($this->isFrontend() && $method === 'id') { // Public documents need no group lookup. Keep the ACL query below for private documents. - $documentObject = SiteContent::query() + $documentObject = SiteContent::toArrays(SiteContent::query() ->where('site_content.id', $identifier) ->where('site_content.privateweb', 0) - ->first(); + ->limit(1))[0] ?? null; } if (is_null($documentObject)) { - $documentObject = SiteContent::query() + $documentObject = SiteContent::toArrays(SiteContent::query() ->withoutProtected() ->where('site_content.' . $method, $identifier) - ->first(); + ->limit(1))[0] ?? null; } if (is_null($documentObject)) { $seclimit = 0; @@ -2961,9 +2961,10 @@ public function getDocumentObject($method, $identifier, $isPrepareResponse = fal $this->sendErrorPage(); } } + // No such document (this used to end in a fatal error on null->toArray()). + $this->sendErrorPage(); } //this is now the document :) - $documentObject = $documentObject->toArray(); unset($documentObject['document_group'], $documentObject['document']); $documentObject['id'] = $identifier; @@ -2992,7 +2993,7 @@ public function getDocumentObject($method, $identifier, $isPrepareResponse = fal ->leftJoin('site_tmplvar_contentvalues', function ($join) use ($documentObject) { $join->on('site_tmplvar_contentvalues.tmplvarid', '=', 'site_tmplvars.id'); $join->on('site_tmplvar_contentvalues.contentid', '=', \DB::raw((int) $documentObject['id'])); - })->where('site_tmplvar_templates.templateid', $documentObject['template'])->get(); + })->where('site_tmplvar_templates.templateid', $documentObject['template'])->toBase()->get(); $tmplvars = []; foreach ($tvs as $tv) { @@ -3242,15 +3243,24 @@ public function executeParser() $this->documentIdentifier = UrlProcessor::getFacadeRoot()->documentListing[$alias]; } else { if ($this->getConfig('aliaslistingfolder') == 1 || $this->getConfig('full_aliaslisting') == 1) { - $parent = $virtualDir ? UrlProcessor::getIdFromAlias($virtualDir) : 0; - $doc = SiteContent::select('id') - ->where('deleted', 0) - ->where('parent', $parent) - ->where('alias', $this->documentIdentifier)->first(); - if (is_null($doc)) { + $docId = null; + // A folder path not in the listing took a query per segment; a path + // of plain aliases is resolved with one, anything else as before. + if ($virtualDir != '' && !isset(UrlProcessor::getFacadeRoot()->documentListing[$virtualDir])) { + $docId = UrlProcessor::getFacadeRoot()->findIdByAliasPath($alias); + } + if ($docId === null) { + $parent = $virtualDir ? UrlProcessor::getIdFromAlias($virtualDir) : 0; + $docId = SiteContent::query() + ->where('deleted', 0) + ->where('parent', $parent) + ->where('alias', $this->documentIdentifier) + ->toBase()->value('id'); + } + if (is_null($docId)) { $this->sendErrorPage(); } - $this->documentIdentifier = $doc->getKey(); + $this->documentIdentifier = (int) $docId; } else { $this->sendErrorPage(); } @@ -3261,13 +3271,14 @@ public function executeParser() $this->documentIdentifier = UrlProcessor::getFacadeRoot() ->documentListing[$this->documentIdentifier]; } else { - $doc = SiteContent::select('id') + $docId = SiteContent::query() ->where('deleted', 0) - ->where('alias', $this->documentIdentifier)->first(); - if (is_null($doc)) { + ->where('alias', $this->documentIdentifier) + ->toBase()->value('id'); + if (is_null($docId)) { $this->sendErrorPage(); } - $this->documentIdentifier = $doc->getKey(); + $this->documentIdentifier = (int) $docId; } } $this->documentMethod = 'id'; @@ -4261,7 +4272,7 @@ public function getAllChildren($id = 0, $sort = 'menuindex', $dir = 'ASC', $fiel $content->withoutProtected(); } // build query - $resourceArray = $content->get()->toArray(); + $resourceArray = SiteContent::toArrays($content); $this->tmpCache[__FUNCTION__][$cacheKey] = $resourceArray; return $resourceArray; @@ -4302,7 +4313,7 @@ public function getActiveChildren($id = 0, $sort = 'menuindex', $dir = 'ASC', $f $content->withoutProtected(); } // build query - $resourceArray = $content->get()->toArray(); + $resourceArray = SiteContent::toArrays($content); $this->tmpCache[__FUNCTION__][$cacheKey] = $resourceArray; return $resourceArray; } @@ -4386,7 +4397,7 @@ public function getDocumentChildren( if (is_numeric($limit)) { $documentChildren = $documentChildren->take($limit); } - $resourceArray = $documentChildren->get()->toArray(); + $resourceArray = SiteContent::toArrays($documentChildren); $this->tmpCache[__FUNCTION__][$cacheKey] = $resourceArray; @@ -4478,7 +4489,7 @@ public function getDocuments( if (is_numeric($limit)) { $documentChildren = $documentChildren->take($limit); } - $resourceArray = $documentChildren->get()->toArray(); + $resourceArray = SiteContent::toArrays($documentChildren); $this->tmpCache[__FUNCTION__][$cacheKey] = $resourceArray; diff --git a/core/src/Legacy/Cache.php b/core/src/Legacy/Cache.php index 65a562b304..6aae319592 100644 --- a/core/src/Legacy/Cache.php +++ b/core/src/Legacy/Cache.php @@ -330,6 +330,13 @@ protected function writeSiteCache($evo) $content .= '$c[\'' . $systemSetting->setting_name . '\']="' . $this->escapeDoubleQuotes($systemSetting->setting_value) . '";'; $config[$systemSetting->setting_name] = $systemSetting->setting_value; } + // Text defaults the database does not store, already translated: a request + // then reads them here instead of building the translator for them. + if (method_exists($evo, 'getFactoryTextDefaults')) { + foreach ($evo->getFactoryTextDefaults($config) as $name => $value) { + $content .= '$c[\'' . $name . '\']="' . $this->escapeDoubleQuotes($value) . '";'; + } + } if (isset($config['enable_filter']) && $config['enable_filter'] == 1) { if (Models\SitePlugin::activePhx()->count()) { diff --git a/core/src/Models/SiteContent.php b/core/src/Models/SiteContent.php index 1968d0258e..46415ffc07 100644 --- a/core/src/Models/SiteContent.php +++ b/core/src/Models/SiteContent.php @@ -184,17 +184,29 @@ public function __construct(array $attributes = []) $attributes[$position] = 0; } - $this->closure = new $this->closure; - - // The default class name of the closure table was not changed - // so we define and set default closure table name automagically. - // This can prevent useless copy paste of closure table models. - if (get_class($this->closure) === ClosureTable::class) { - $table = $this->getTable() . '_closure'; - $this->closure->setTable($table); + parent::__construct($attributes); + } + + /** + * The closure table model, built on first use: reading documents never + * needs it, and building one for every hydrated row doubled the models. + * + * @return ClosureTable + */ + protected function closureTable(): ClosureTable + { + if (!$this->closure instanceof ClosureTable) { + $this->closure = new $this->closure; + + // The default class name of the closure table was not changed + // so we define and set default closure table name automagically. + // This can prevent useless copy paste of closure table models. + if (get_class($this->closure) === ClosureTable::class) { + $this->closure->setTable($this->getTable() . '_closure'); + } } - parent::__construct($attributes); + return $this->closure; } // adjust boot function @@ -225,7 +237,7 @@ public static function boot() $descendant = $entity->getKey(); $ancestor = isset($entity->parent) ? $entity->parent : $descendant; - $entity->closure->insertNode($ancestor, $descendant); + $entity->closureTable()->insertNode($ancestor, $descendant); }); static::saved(static function (SiteContent $entity) { @@ -235,15 +247,15 @@ public static function boot() $entity->reorderSiblings(); } - if ($entity->closure->ancestor === null) { + if ($entity->closureTable()->ancestor === null) { $primaryKey = $entity->getKey(); - $entity->closure->ancestor = $primaryKey; - $entity->closure->descendant = $primaryKey; - $entity->closure->depth = 0; + $entity->closureTable()->ancestor = $primaryKey; + $entity->closureTable()->descendant = $primaryKey; + $entity->closureTable()->depth = 0; } if ($parentIdChanged) { - $entity->closure->moveNodeTo($entity->parent); + $entity->closureTable()->moveNodeTo($entity->parent); } }); @@ -716,13 +728,13 @@ private function buildAncestorsQuery(Builder $builder, $id, $withSelf) return $builder ->join( - $this->closure->getTable(), - $this->closure->getAncestorColumn(), + $this->closureTable()->getTable(), + $this->closureTable()->getAncestorColumn(), '=', $this->getQualifiedKeyName() ) - ->where($this->closure->getDescendantColumn(), '=', $id) - ->where($this->closure->getDepthColumn(), $depthOperator, 0); + ->where($this->closureTable()->getDescendantColumn(), '=', $id) + ->where($this->closureTable()->getDepthColumn(), $depthOperator, 0); } /** @@ -822,13 +834,13 @@ private function buildDescendantsQuery(Builder $builder, $id, $withSelf) return $builder ->join( - $this->closure->getTable(), - $this->closure->getDescendantColumn(), + $this->closureTable()->getTable(), + $this->closureTable()->getDescendantColumn(), '=', $this->getQualifiedKeyName() ) - ->where($this->closure->getAncestorColumn(), '=', $id) - ->where($this->closure->getDepthColumn(), $depthOperator, 0); + ->where($this->closureTable()->getAncestorColumn(), '=', $id) + ->where($this->closureTable()->getDepthColumn(), $depthOperator, 0); } /** @@ -2022,7 +2034,7 @@ public function deleteSubtree($withSelf = false, $forceDelete = false) $ids = $query->pluck($this->getKeyName()); if ($forceDelete) { - $this->closure->whereIn($this->closure->getDescendantColumn(), $ids)->delete(); + $this->closureTable()->whereIn($this->closureTable()->getDescendantColumn(), $ids)->delete(); } $this->whereIn($this->getKeyName(), $ids)->$action(); @@ -2067,11 +2079,100 @@ public function scopeActive($query) return $query->where('published', '1')->where('deleted', '0'); } + /** + * The rows of a document query as arrays, the same as `$query->get()->toArray()`. + * + * No model is hydrated per row only to be turned back into an array: rows + * whose columns have no accessor and only scalar casts (the usual + * id/pagetitle/alias listing) are cast directly, any other row goes through + * one shared model instance, so dates and accessors stay Eloquent's own. + * The "retrieved" model event does not fire for these rows. + * + * @param Builder $query + * @return array> + */ + public static function toArrays(Builder $query): array + { + $model = $query->getModel()->newInstance([], true); + $rows = []; + $scalarCasts = null; + foreach ($query->toBase()->get() as $row) { + $row = (array) $row; + // Every row of one query has the same columns: decide once. + $scalarCasts ??= $model->scalarCastsFor(array_keys($row)); + if ($scalarCasts === false) { + // toArray() without its recursion guard, which hashes a backtrace + // per call: a row model has no relations to recurse into. + $model->setRawAttributes($row); + $rows[] = array_merge($model->attributesToArray(), $model->relationsToArray()); + continue; + } + foreach ($scalarCasts as $key => $type) { + if ($row[$key] !== null) { + $row[$key] = match ($type) { + 'int', 'integer' => (int) $row[$key], + 'bool', 'boolean' => (bool) $row[$key], + 'string' => (string) $row[$key], + // The deleted-at column, as SoftDeletes::addCastAttributesToArray() keeps it. + 'timestamp' => is_numeric($row[$key]) ? (int) $row[$key] : $row[$key], + }; + } + } + $rows[] = $row; + } + + return $rows; + } + + /** + * The casts of the given columns when toArray() would do nothing else to them + * than an int, bool or string cast (or the deleted-at timestamp SoftDeletes keeps); + * false when one of them needs the model (an accessor, a float, date or object + * cast, hidden/visible/appended attributes). + * + * @param list $columns + * @return array|false + */ + protected function scalarCastsFor(array $columns): array|false + { + // A subclass may change how a model becomes an array; it keeps doing so. + if (static::class !== self::class + || $this->getHidden() || $this->getVisible() || $this->getAppends() + || array_intersect($columns, $this->getMutatedAttributes())) { + return false; + } + $deletedAt = $this->getDeletedAtColumn(); + $casts = []; + foreach (array_intersect_key($this->getCasts(), array_flip($columns)) as $key => $type) { + $type = strtolower(trim((string) $type)); + if ($key === $deletedAt && $type === 'datetime') { + // Arrayed as its stored timestamp, not as a date (see SoftDeletes). + $casts[$key] = 'timestamp'; + continue; + } + if (!in_array($type, ['int', 'integer', 'bool', 'boolean', 'string'], true)) { + return false; + } + $casts[$key] = $type; + } + foreach ($columns as $column) { + if ($column !== $deletedAt && $this->isDateAttribute($column)) { + return false; + } + } + + return $casts; + } + public function scopeWithoutProtected($query) { - $query->leftJoin('document_groups', 'document_groups.document', '=', 'site_content.id'); - $query->where(function($query){ - $docgrp = evo()->getUserDocGroups(); + $docgrp = evo()->getUserDocGroups(); + // The join only serves the group condition below: without groups it would + // cost a lookup per row (and repeat a document once per group it is in). + if ($docgrp) { + $query->leftJoin('document_groups', 'document_groups.document', '=', 'site_content.id'); + } + $query->where(function($query) use ($docgrp) { if (evo()->isFrontend()) { $query->where('privateweb', 0); } else { diff --git a/core/src/Parser.php b/core/src/Parser.php index 27e5ebad24..eeb983eb1f 100644 --- a/core/src/Parser.php +++ b/core/src/Parser.php @@ -6,6 +6,7 @@ use Illuminate\View\FileViewFinder; /** + * @property \Illuminate\View\Factory|null $blade Built on first read. */ class Parser { @@ -35,7 +36,28 @@ class Parser protected $templateExtension = 'html'; - public $blade; + /** + * The Blade view behind $blade, built on first read (see __get): most pages + * never render a Blade chunk, and building it resolved the whole view factory + * per request. $blade itself is not declared, so reading it reaches __get. + * + * @var \Illuminate\View\Factory|null + */ + protected $bladeView; + + /** + * Whether $bladeView was built (or building it failed), so it is built once. + * + * @var bool + */ + protected $bladeLoaded = false; + + /** + * The view path setTemplatePath() gave $blade, kept until $blade is built. + * + * @var string|null + */ + protected $bladeViewPath; protected $bladeEnabled = true; @@ -65,7 +87,56 @@ public static function getInstance (Core $modx) private function __construct (Core $modx) { $this->modx = $modx; - $this->loadBlade(); + } + + /** + * Builds $blade the first time it is read. + * + * @param string $name + * @return mixed + */ + public function __get ($name) + { + if ($name !== 'blade') { + trigger_error('Undefined property: ' . static::class . '::$' . $name, E_USER_WARNING); + + return null; + } + if (!$this->bladeLoaded) { + $this->loadBlade(); + } + + return $this->bladeView; + } + + /** + * Replaces the Blade view; other names keep PHP's default behaviour. + * + * @param string $name + * @param mixed $value + * @return void + */ + public function __set ($name, $value) + { + if ($name !== 'blade') { + $this->$name = $value; + + return; + } + $this->bladeView = $value; + $this->bladeLoaded = true; + } + + /** + * $blade counts as set, as it did when it was built eagerly, unless it was + * set to null or failed to build; checking it does not build it. + * + * @param string $name + * @return bool + */ + public function __isset ($name) + { + return $name === 'blade' && (!$this->bladeLoaded || $this->bladeView !== null); } /** @@ -110,10 +181,10 @@ public function setTemplatePath ($path, $supRoot = false) if (!empty($path)) { $this->templatePath = $path; - if ($this->blade) { - $filesystem = new Filesystem; - $viewFinder = new FileViewFinder($filesystem, [EVO_BASE_PATH . $path]); - $this->blade->setFinder($viewFinder); + $this->bladeViewPath = EVO_BASE_PATH . $path; + // A $blade built later picks the path up in loadBlade(). + if ($this->bladeView !== null) { + $this->bladeView->setFinder(new FileViewFinder(new Filesystem, [$this->bladeViewPath])); } } @@ -484,9 +555,14 @@ public function setPHxPlaceholders ($value = '', $key = '', $path = '') */ protected function loadBlade () { + $this->bladeLoaded = true; try { - $this->blade = clone $this->modx['view']; + $this->bladeView = clone $this->modx['view']; + if ($this->bladeViewPath !== null) { + $this->bladeView->setFinder(new FileViewFinder(new Filesystem, [$this->bladeViewPath])); + } } catch (\Exception $exception) { + $this->bladeView = null; $this->modx->messageQuit($exception->getMessage()); } } diff --git a/core/src/Providers/BladeIconsAdapterServiceProvider.php b/core/src/Providers/BladeIconsAdapterServiceProvider.php index f1cf39ad6e..7496048627 100644 --- a/core/src/Providers/BladeIconsAdapterServiceProvider.php +++ b/core/src/Providers/BladeIconsAdapterServiceProvider.php @@ -3,9 +3,9 @@ use BladeUI\Icons\Factory; use BladeUI\Icons\IconsManifest; use Illuminate\Contracts\Filesystem\Factory as FilesystemFactory; -use Illuminate\Contracts\View\Factory as ViewFactory; use Illuminate\Filesystem\Filesystem; use Illuminate\Support\ServiceProvider; +use Illuminate\View\Compilers\BladeCompiler; /** * Adapter for Blade Icons to work with Evolution CMS @@ -59,22 +59,22 @@ private function registerManifest(): void private function bootDirectives(): void { - // Register Blade directives without type-hint issues - $this->callAfterResolving(ViewFactory::class, function ($view) { + // Register Blade directives without type-hint issues. On the compiler + // itself (the one the Blade engine uses): a page that only resolves the + // view factory, e.g. to look for a template file, then does not build it. + $this->callAfterResolving('blade.compiler', function (BladeCompiler $blade) { // Register @svg directive - $view->getEngineResolver() - ->resolve('blade') - ->getCompiler() - ->directive('svg', function ($expression) { - return ""; - }); + $blade->directive('svg', function ($expression) { + return ""; + }); }); } private function bootIconComponent(): void { - // Register icon component without Application type-hint - $this->callAfterResolving(ViewFactory::class, function ($view) { + // Register icon component without Application type-hint; components are + // compiler state as well, so they wait for the compiler like the directive. + $this->callAfterResolving('blade.compiler', function () { if (!is_file($this->manifestPath())) { return; } diff --git a/core/src/Providers/BladeServiceProvider.php b/core/src/Providers/BladeServiceProvider.php index 9ffebad782..a7d5cb8c66 100644 --- a/core/src/Providers/BladeServiceProvider.php +++ b/core/src/Providers/BladeServiceProvider.php @@ -1,18 +1,27 @@ callAfterResolving('blade.compiler', function (BladeCompiler $blade) { + $this->registerDirectives($blade); + }); + } + + protected function registerDirectives(BladeCompiler $blade): void + { + $blade->directive('evoConfig', function ($expression) { $expression = $expression ?: "''"; return "getConfig($expression)); ?>"; }); - Blade::directive('makeUrl', function ($expression) { + $blade->directive('makeUrl', function ($expression) { $expression = $expression ?: "''"; return "makeUrlWithString($expression)); ?>"; }); @@ -22,32 +31,32 @@ public function boot() * * @since 3.5.8 */ - Blade::directive('revision', function ($expression) { + $blade->directive('revision', function ($expression) { $expression = $expression ?: "''"; return ""; }); - Blade::directive('evoParser', function ($expression) { + $blade->directive('evoParser', function ($expression) { $expression = $expression ?: "''"; return ""; }); - Blade::directive('evoRole', function ($expression) { + $blade->directive('evoRole', function ($expression) { $expression = $expression ?: "''"; return ""; }); - Blade::directive('evoElseRole', function ($expression) { + $blade->directive('evoElseRole', function ($expression) { $expression = $expression ?: "''"; return ""; }); - Blade::directive('evoEndRole', function () { + $blade->directive('evoEndRole', function () { return ""; }); - Blade::if('auth', fn () => evo()->getLoginUserID() !== false); - Blade::if('guest', fn () => evo()->getLoginUserID() === false); + $blade->if('auth', fn () => evo()->getLoginUserID() !== false); + $blade->if('guest', fn () => evo()->getLoginUserID() === false); /** * @deprecated @@ -60,7 +69,7 @@ public function boot() $directives = $this->app['config']->get('view.directive'); if (\is_array($directives)) { foreach ($directives as $name => $callback) { - $this->app->get('blade.compiler')->directive($name, $callback); + $blade->directive($name, $callback); } } } diff --git a/core/src/TemplateProcessor.php b/core/src/TemplateProcessor.php index 8790851fdf..33a72e8f69 100644 --- a/core/src/TemplateProcessor.php +++ b/core/src/TemplateProcessor.php @@ -164,7 +164,7 @@ private function templateSource(array $doc): string * Template rows keyed by ID for this processor instance. Missing IDs are * stored as null so repeated lookups do not issue another query. * - * @var array + * @var array */ private array $templateRows = []; @@ -182,17 +182,20 @@ public function getTemplateAlias(int $templateId): string /** * Load the fields needed for document loading and rendering once per ID. * + * A plain row, not a model: only these columns are read, every page needs + * them, and none of them has a cast or an accessor. + * * @param int $templateId - * @return SiteTemplate|null Null for template ID 0 or a missing row. + * @return object|null Null for template ID 0 or a missing row. */ - private function templateRow(int $templateId): ?SiteTemplate + private function templateRow(int $templateId): ?object { if ($templateId === 0) { return null; } if (!array_key_exists($templateId, $this->templateRows)) { - $this->templateRows[$templateId] = SiteTemplate::whereKey($templateId) + $this->templateRows[$templateId] = SiteTemplate::query()->whereKey($templateId)->toBase() ->first(['id', 'templatealias', 'templatesource', 'templatefileextension', 'content']); } diff --git a/core/src/Traits/Path.php b/core/src/Traits/Path.php index 8559d24c5a..5fa3ff81a1 100644 --- a/core/src/Traits/Path.php +++ b/core/src/Traits/Path.php @@ -356,7 +356,11 @@ public function routesAreCached() public function setLocale($locale) { $this['config']->set('app.locale', $locale); - $this['translator']->setLocale($locale); + // A translator built later reads app.locale; a request that never + // translates anything does not need one built here. + if ($this->resolved('translator')) { + $this['translator']->setLocale($locale); + } } /** diff --git a/core/src/Traits/Settings.php b/core/src/Traits/Settings.php index 2a1e5fb714..2b144dd926 100644 --- a/core/src/Traits/Settings.php +++ b/core/src/Traits/Settings.php @@ -118,7 +118,8 @@ public function getConfig($name = '', $default = null) */ public function getSettings() { - $this->config = array_merge($this->getFactorySettings(), $this->config); + // The current values replace the defaults, so their defaults need no translation. + $this->config = array_merge($this->getFactorySettings($this->config), $this->config); // setup default site id - new installation should generate a unique id for the site. if ($this->getConfig('site_id', '') === '') { @@ -164,10 +165,12 @@ public function getSettings() * its original order. Frontend requests retain the locale and manager view * namespace while deferring theme element scans until those elements are used. * + * @param array $known Settings the caller already has values for: their + * defaults are returned as these values, untranslated. * @return array * @since 3.5.9 Updated to read defaults without eager frontend theme creation. */ - public function getFactorySettings() : array + public function getFactorySettings(array $known = []) : array { $managerLanguage = (string) $this->getConfig('manager_language', 'en'); $languageFile = EVO_CORE_PATH . 'lang/' . $managerLanguage . '/global.php'; @@ -196,6 +199,48 @@ public function getFactorySettings() : array $this->registerFrontendManagerViewNamespace($theme); } + return $this->readFactorySettings($factoryLocale, $known); + } + + /** + * The text defaults the stored settings lack, as the site cache keeps them. + * + * Factory defaults such as the e-mail texts are translated into the manager + * language and rarely stored, so reading them used to build the translator + * and load a language file on every request. The site cache stores these + * strings instead (it is rebuilt whenever the settings change); defaults of + * other types are left to getFactorySettings(), which keeps their type. + * + * @param array $stored The system settings as stored. + * @return array + * @since 3.5.9 + */ + public function getFactoryTextDefaults(array $stored): array + { + // The language getConfig('manager_language', 'en') reads once these settings are loaded. + $language = (string) ($stored['manager_language'] ?? ''); + $language = (string) $this['config']->get('cms.settings.manager_language', $language === '' ? 'en' : $language); + $locale = is_file(EVO_CORE_PATH . 'lang/' . $language . '/global.php') ? $language : 'en'; + + $defaults = []; + foreach ($this->readFactorySettings($locale, $stored) as $name => $value) { + if (\is_string($value) && !array_key_exists($name, $stored)) { + $defaults[$name] = $value; + } + } + + return $defaults; + } + + /** + * Evaluate the factory defaults file in the given language. + * + * @param string $factoryLocale Language the default texts are translated into. + * @param array $known Settings whose defaults are not translated. + * @return array + */ + protected function readFactorySettings(string $factoryLocale, array $known = []): array + { $out = include EVO_CORE_PATH . 'factory/settings.php'; return \is_array($out) ? $out : []; } diff --git a/core/src/UrlProcessor.php b/core/src/UrlProcessor.php index 450f8b01f0..53cea5c2bb 100644 --- a/core/src/UrlProcessor.php +++ b/core/src/UrlProcessor.php @@ -480,15 +480,7 @@ public function getIdFromAlias($alias) } if (!$this->core->getConfig('use_alias_path')) { - /** @var Models\SiteContent $query */ - $query = Models\SiteContent::where('deleted', '=', 0) - ->where('alias', '=', $alias) - ->first(); - - if ($query === null) { - return null; - } - return $query->getKey(); + return $this->findDocumentId(['alias' => $alias]); } if ($alias === '.') { @@ -507,29 +499,67 @@ public function getIdFromAlias($alias) if ($id === null) { break; } - /** @var Models\SiteContent $query */ - $query = Models\SiteContent::where('deleted', '=', 0) - ->where('parent', '=', $id) - ->where('alias', '=', $tmp) - ->first(); + $found = $this->findDocumentId(['parent' => $id, 'alias' => $tmp]) + ?? $this->findDocumentId(['parent' => $id, 'id' => $tmp]); - if ($query === null) { - /** @var Models\SiteContent $query */ - $query = Models\SiteContent::where('deleted', '=', 0) - ->where('parent', '=', $id) - ->where('id', '=', $tmp) - ->first(); - } - - if ($query === null) { - $id = $this->getHiddenIdFromAlias($id, $tmp); - } else { - $id = $query->getKey(); - } + $id = $found ?? $this->getHiddenIdFromAlias($id, $tmp); } return $id; } + /** + * The id of the document at an alias path, read with one query. + * + * Every segment must be the alias of a non-deleted child of the previous + * one, starting at the root. Null when that does not hold, and the caller + * walks the path one segment at a time as before (getIdFromAlias() also + * accepts ids as segments and looks through hidden folders). + * + * @param string $path Alias path such as "articles/category-042". + * @since 3.5.9 + */ + public function findIdByAliasPath(string $path): ?int + { + $segments = explode('/', trim($path, '/')); + if (in_array('', $segments, true)) { + return null; + } + + $query = Models\SiteContent::query()->getConnection() + ->table('site_content as d0') + ->where('d0.parent', '=', 0) + ->where('d0.alias', '=', $segments[0]) + ->where('d0.deleted', '=', 0); + $last = count($segments) - 1; + for ($i = 1; $i <= $last; $i++) { + $query->join('site_content as d' . $i, 'd' . $i . '.parent', '=', 'd' . ($i - 1) . '.id') + ->where('d' . $i . '.alias', '=', $segments[$i]) + ->where('d' . $i . '.deleted', '=', 0); + } + $id = $query->value('d' . $last . '.id'); + + return $id === null ? null : (int) $id; + } + + /** + * The id of the first non-deleted document matching the given columns. + * + * Reads the id column only, without building a model: the alias walk runs + * on every friendly-URL request, one query per path segment. + * + * @param array $where + */ + protected function findDocumentId(array $where): ?int + { + $query = Models\SiteContent::query()->where('deleted', '=', 0); + foreach ($where as $column => $value) { + $query->where($column, '=', $value); + } + $id = $query->toBase()->value('id'); + + return $id === null ? null : (int) $id; + } + /** * @param int $parentid * @param string $alias diff --git a/core/tests/Unit/BladeDirectivesOnDemandTest.php b/core/tests/Unit/BladeDirectivesOnDemandTest.php new file mode 100644 index 0000000000..95933c59b7 --- /dev/null +++ b/core/tests/Unit/BladeDirectivesOnDemandTest.php @@ -0,0 +1,67 @@ +instance('config', new Repository(['view' => ['directive' => ['legacyDirective' => fn () => 'legacy']]])); + $app->singleton('blade.compiler', fn () => new BladeCompiler(new Filesystem(), sys_get_temp_dir())); + $app->singleton('view', fn () => new Factory(new EngineResolver(), new FileViewFinder(new Filesystem(), []), new Dispatcher())); + $this->app = $app; +}); + +test('booting the providers does not build the compiler', function () { + (new BladeServiceProvider($this->app))->boot(); + (new BladeIconsAdapterServiceProvider($this->app))->boot(); + $this->app->make('view'); + + expect($this->app->resolved('blade.compiler'))->toBeFalse(); +}); + +test('the compiler gets the directives once it is built', function () { + (new BladeServiceProvider($this->app))->boot(); + (new BladeIconsAdapterServiceProvider($this->app))->boot(); + + $directives = $this->app->make('blade.compiler')->getCustomDirectives(); + + expect($directives)->toHaveKeys(['evoConfig', 'makeUrl', 'revision', 'evoParser', 'evoRole', 'evoElseRole', 'evoEndRole', 'auth', 'guest', 'legacyDirective', 'svg']) + ->and($this->app->make('blade.compiler')->compileString('@evoEndRole'))->toBe(''); +}); + +test('a compiler built before the providers boot gets the directives too', function () { + $compiler = $this->app->make('blade.compiler'); + + (new BladeServiceProvider($this->app))->boot(); + + expect($compiler->getCustomDirectives())->toHaveKey('evoConfig'); +}); diff --git a/core/tests/Unit/FactorySettingsTranslationTest.php b/core/tests/Unit/FactorySettingsTranslationTest.php new file mode 100644 index 0000000000..c6134fa5b2 --- /dev/null +++ b/core/tests/Unit/FactorySettingsTranslationTest.php @@ -0,0 +1,102 @@ +settings = new class extends Container { + use Settings; + + public array $translated = []; + + public function isBackend(): bool + { + return false; + } + + public function setLocale($locale): void + { + } + }; + $this->settings->instance('config', new Repository([])); + $translator = new class(new FileLoader(new Filesystem(), EVO_CORE_PATH . 'lang'), 'en') extends Translator { + public array $asked = []; + + public function get($key, array $replace = [], $locale = null, $fallback = true) + { + $this->asked[] = $key; + + return parent::get($key, $replace, $locale, $fallback); + } + }; + $this->settings->instance('translator', $translator); + $this->translator = $translator; + + // __() reaches the translator through app() and evo(). + global $evo; + $evo = $this->settings; +}); + +afterEach(function () { + global $evo; + $evo = null; +}); + +test('without known values every default text is translated', function () { + $defaults = $this->settings->getFactorySettings(); + + expect($this->translator->asked)->toHaveCount(8) + ->and($defaults['emailsubject'])->toBe(__('global.emailsubject_default')) + ->and($defaults['site_start'])->toBe(1); +}); + +test('a default the caller already has a value for is not translated', function () { + $defaults = $this->settings->getFactorySettings(['emailsubject' => 'Hello', 'captcha_words' => 'a,b']); + + expect($defaults['emailsubject'])->toBe('Hello') + ->and($defaults['captcha_words'])->toBe('a,b') + ->and($this->translator->asked)->not->toContain('global.emailsubject_default', 'global.captcha_words_default') + ->and($this->translator->asked)->toHaveCount(6); +}); + +test('the site cache gets the string defaults the stored settings lack', function () { + $defaults = $this->settings->getFactoryTextDefaults(['emailsubject' => 'Stored', 'site_name' => 'Mine']); + + expect($defaults)->not->toHaveKeys(['emailsubject', 'site_name']) + // Not a string: getFactorySettings() keeps the type of these per request. + ->and($defaults)->not->toHaveKey('site_start') + ->and($defaults['signupemail_message'])->toBe(__('global.system_email_signup', [], 'en')) + ->and($defaults['custom_contenttype'])->toBeString() + ->and(array_filter($defaults, 'is_string'))->toBe($defaults); +}); + +test('the site cache translates into the stored manager language', function () { + $german = $this->settings->getFactoryTextDefaults(['manager_language' => 'de']); + $unknown = $this->settings->getFactoryTextDefaults(['manager_language' => 'xx']); + + expect($german['emailsubject'])->toBe(__('global.emailsubject_default', [], 'de')) + ->and($unknown['emailsubject'])->toBe(__('global.emailsubject_default', [], 'en')); +}); + +test('a manager language set in the configuration files wins over the stored one', function () { + $this->settings['config']->set('cms.settings.manager_language', 'de'); + + $defaults = $this->settings->getFactoryTextDefaults(['manager_language' => 'en']); + + expect($defaults['emailsubject'])->toBe(__('global.emailsubject_default', [], 'de')); +}); diff --git a/core/tests/Unit/Install/CliInstallComposerNoDevTest.php b/core/tests/Unit/Install/CliInstallComposerNoDevTest.php new file mode 100644 index 0000000000..60337024e8 --- /dev/null +++ b/core/tests/Unit/Install/CliInstallComposerNoDevTest.php @@ -0,0 +1,50 @@ +commands[] = $cmd; return array_shift($this->results) ?? true; }' + . ' }' + . ' $install = (new ReflectionClass(RecordingInstall::class))->newInstanceWithoutConstructor();' + . ' $install->results = ' . var_export($results, true) . ';' + . ' ob_start(); $install->composerUpdate(); ob_end_clean();' + . ' echo json_encode($install->commands);'); + $out = trim(evoRunPhp($probe)); + unlink($probe); + + return json_decode($out, true, flags: JSON_THROW_ON_ERROR); +} + +it('updates with the development packages, then removes them', function () use ($root) { + $commands = composerCommandsOfInstall($root, [true, true]); + + expect($commands)->toHaveCount(2) + ->and($commands[0])->toContain(' update ')->not->toContain('--no-dev') + ->and($commands[1])->toContain(' install ')->toContain('--no-dev'); +}); + +it('leaves the packages alone when the update failed', function () use ($root) { + $commands = composerCommandsOfInstall($root, [false]); + + expect($commands)->toHaveCount(1) + ->and($commands[0])->toContain(' update '); +}); diff --git a/core/tests/Unit/Install/ConnectionStubPdoOptionsTest.php b/core/tests/Unit/Install/ConnectionStubPdoOptionsTest.php new file mode 100644 index 0000000000..10435c3a1f --- /dev/null +++ b/core/tests/Unit/Install/ConnectionStubPdoOptionsTest.php @@ -0,0 +1,42 @@ +toBe($type) + ->and($connection['use_db_after_connecting'])->toBeFalse() + ->and($connection['options'][PDO::ATTR_EMULATE_PREPARES])->toBeTrue() + ->and($connection['options'][PDO::ATTR_STRINGIFY_FETCHES])->toBeTrue(); +})->with(['mysql', 'mariadb']); + +it('leaves the prepares of other drivers to PDO', function (string $type) { + $connection = installedConnection($type); + + expect($connection['options'])->not->toHaveKey(PDO::ATTR_EMULATE_PREPARES) + ->and($connection['options'][PDO::ATTR_STRINGIFY_FETCHES])->toBeTrue(); +})->with(['pgsql', 'sqlite']); diff --git a/core/tests/Unit/Install/SiteContentParentMenuindexIndexTest.php b/core/tests/Unit/Install/SiteContentParentMenuindexIndexTest.php new file mode 100644 index 0000000000..8820d7cf53 --- /dev/null +++ b/core/tests/Unit/Install/SiteContentParentMenuindexIndexTest.php @@ -0,0 +1,74 @@ +disconnect(); +}); + +/** A prefixed SQLite database with the facades the migration uses wired to it. */ +function bootParentMenuindexDatabase(): Capsule +{ + $capsule = new Capsule(); + $capsule->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'evo_']); + $capsule->setAsGlobal(); + + $container = $capsule->getContainer(); + $container->instance('db', $capsule->getDatabaseManager()); + $container->bind('db.schema', fn () => $capsule->getConnection()->getSchemaBuilder()); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication($container); + class_exists('DB', false) || class_alias(\Illuminate\Support\Facades\DB::class, 'DB'); + + $capsule->getConnection()->getSchemaBuilder()->create('site_content', function (Blueprint $table) { + $table->increments('id'); + $table->integer('parent')->default(0)->index('evo_site_content_parent'); + $table->integer('menuindex')->default(0); + }); + + return $capsule; +} + +/** @return array> index name => columns */ +function siteContentIndexes(Capsule $capsule): array +{ + $indexes = []; + foreach ($capsule->getConnection()->getSchemaBuilder()->getIndexes('site_content') as $index) { + $indexes[$index['name']] = $index['columns']; + } + + return $indexes; +} + +test('the migration indexes children in menu order, once', function () { + $capsule = bootParentMenuindexDatabase(); + class_exists('AddParentMenuindexIndexToSiteContent', false) + || require dirname(__DIR__, 3) . '/database/migrations/2026_10_01_000000_add_parent_menuindex_index_to_site_content.php'; + $migration = new AddParentMenuindexIndexToSiteContent(); + + $migration->up(); + $migration->up(); + + $indexes = siteContentIndexes($capsule); + expect($indexes['evo_site_content_parent_menuindex'] ?? null)->toBe(['parent', 'menuindex']) + // The single-column index stays for queries that filter by parent only. + ->and($indexes['evo_site_content_parent'] ?? null)->toBe(['parent']) + ->and(array_filter($indexes, fn ($columns) => $columns === ['parent', 'menuindex']))->toHaveCount(1); +}); + +test('rolling the migration back drops only its index', function () { + $capsule = bootParentMenuindexDatabase(); + class_exists('AddParentMenuindexIndexToSiteContent', false) + || require dirname(__DIR__, 3) . '/database/migrations/2026_10_01_000000_add_parent_menuindex_index_to_site_content.php'; + $migration = new AddParentMenuindexIndexToSiteContent(); + + $migration->up(); + $migration->down(); + $migration->down(); + + expect(siteContentIndexes($capsule))->not->toHaveKey('evo_site_content_parent_menuindex') + ->and(siteContentIndexes($capsule))->toHaveKey('evo_site_content_parent'); +}); diff --git a/core/tests/Unit/Models/SiteContentToArraysTest.php b/core/tests/Unit/Models/SiteContentToArraysTest.php new file mode 100644 index 0000000000..6fa49aafb7 --- /dev/null +++ b/core/tests/Unit/Models/SiteContentToArraysTest.php @@ -0,0 +1,102 @@ +addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'evo_']); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + Model::setConnectionResolver($capsule->getDatabaseManager()); + + $capsule->getConnection()->getSchemaBuilder()->create('site_content', function (Blueprint $table) { + $table->increments('id'); + $table->string('pagetitle')->default(''); + $table->string('alias')->default(''); + $table->integer('parent')->default(0); + $table->integer('published')->default(1); + $table->integer('pub_date')->default(0); + $table->integer('menuindex')->default(0); + $table->integer('hidemenu')->default(0); + $table->integer('richtext')->default(1); + $table->integer('deleted')->default(0); + $table->integer('deletedon')->default(0); + $table->text('content')->nullable(); + }); + foreach ([ + ['id' => 1, 'pagetitle' => 'Folder', 'alias' => 'folder', 'parent' => 0, 'menuindex' => 0, 'content' => null], + ['id' => 2, 'pagetitle' => 'Second', 'alias' => 'second', 'parent' => 1, 'menuindex' => 2, 'pub_date' => 1700000000, 'hidemenu' => 1, 'content' => 'b'], + ['id' => 3, 'pagetitle' => 'First', 'alias' => 'first', 'parent' => 1, 'menuindex' => 1, 'deletedon' => 1790625541, 'content' => 'a'], + ['id' => 4, 'pagetitle' => 'Gone', 'alias' => 'gone', 'parent' => 1, 'menuindex' => 3, 'deleted' => 1, 'content' => 'c'], + ] as $row) { + Capsule::table('site_content')->insert($row); + } + + return $capsule; +} + +afterEach(fn () => Capsule::connection()->disconnect()); + +test('a listing of scalar columns is the same as get()->toArray()', function () { + bootToArraysDatabase(); + $query = fn () => SiteContent::query() + ->select(['site_content.id', 'site_content.pagetitle', 'site_content.pub_date', 'site_content.hidemenu', 'site_content.richtext']) + ->where('site_content.parent', 1) + ->orderBy('menuindex'); + + $rows = SiteContent::toArrays($query()); + + expect($rows)->toBe($query()->get()->toArray()) + ->and($rows)->toBe([ + ['id' => 3, 'pagetitle' => 'First', 'pub_date' => 0, 'hidemenu' => false, 'richtext' => true], + ['id' => 2, 'pagetitle' => 'Second', 'pub_date' => 1700000000, 'hidemenu' => true, 'richtext' => true], + ]); +}); + +test('whole rows with the deleted-at column are the same as get()->toArray()', function () { + bootToArraysDatabase(); + $query = fn () => SiteContent::query()->withTrashed()->orderBy('id'); + + $rows = SiteContent::toArrays($query()); + + expect($rows)->toBe($query()->get()->toArray()) + ->and($rows[2]['deletedon'])->toBe(1790625541) + ->and($rows[0]['content'])->toBeNull() + ->and(array_column($rows, 'id'))->toBe([1, 2, 3, 4]); +}); + +test('global scopes and limits apply as they do to get()', function () { + bootToArraysDatabase(); + + $rows = SiteContent::toArrays(SiteContent::query()->where('parent', 1)->orderBy('id')->limit(1)); + + expect(array_column($rows, 'id'))->toBe([2]) + ->and(SiteContent::toArrays(SiteContent::query()->where('parent', 1)))->toHaveCount(2); +}); + +test('an empty result is an empty array', function () { + bootToArraysDatabase(); + + expect(SiteContent::toArrays(SiteContent::query()->where('parent', 99)))->toBe([]); +}); + +test('a model with an accessor on a selected column still goes through the model', function () { + bootToArraysDatabase(); + $model = new class extends SiteContent { + public function getPagetitleAttribute($value): string + { + return strtoupper((string) $value); + } + }; + $query = fn () => $model->newQuery()->select(['site_content.id', 'site_content.pagetitle'])->where('site_content.id', 2); + + expect(SiteContent::toArrays($query()))->toBe($query()->get()->toArray()) + ->and(SiteContent::toArrays($query())[0]['pagetitle'])->toBe('SECOND'); +}); diff --git a/core/tests/Unit/Models/SiteContentWithoutProtectedTest.php b/core/tests/Unit/Models/SiteContentWithoutProtectedTest.php new file mode 100644 index 0000000000..d0b8439bdd --- /dev/null +++ b/core/tests/Unit/Models/SiteContentWithoutProtectedTest.php @@ -0,0 +1,92 @@ +addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'evo_']); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + Model::setConnectionResolver($capsule->getDatabaseManager()); + + $schema = $capsule->getConnection()->getSchemaBuilder(); + $schema->create('site_content', function (Blueprint $table) { + $table->increments('id'); + $table->integer('parent')->default(0); + $table->integer('privateweb')->default(0); + $table->integer('privatemgr')->default(0); + $table->integer('deleted')->default(0); + }); + $schema->create('document_groups', function (Blueprint $table) { + $table->increments('id'); + $table->integer('document_group'); + $table->integer('document'); + }); + Capsule::table('site_content')->insert([ + ['id' => 1, 'privateweb' => 0], + ['id' => 2, 'privateweb' => 0], + ['id' => 3, 'privateweb' => 1], + ]); + Capsule::table('document_groups')->insert([ + ['document_group' => 7, 'document' => 1], + ['document_group' => 8, 'document' => 1], + ['document_group' => 5, 'document' => 3], + ]); +}); + +afterEach(function () { + global $evo; + $evo = null; + Capsule::connection()->disconnect(); +}); + +/** A front-end visitor in the given document groups, as evo() hands it out. */ +function frontendVisitorInGroups(array $groups): void +{ + global $evo; + $evo = new class($groups) { + public function __construct(private array $groups) + { + } + + public function getUserDocGroups() + { + return $this->groups ?: ''; + } + + public function isFrontend(): bool + { + return true; + } + }; +} + +test('a visitor without document groups gets public documents without the group join', function () { + frontendVisitorInGroups([]); + $query = SiteContent::query()->select('site_content.id')->withoutProtected()->orderBy('site_content.id'); + + expect($query->toSql())->not->toContain('document_groups') + // Document 1 is in two groups: the join used to list it twice. + ->and($query->pluck('id')->all())->toBe([1, 2]); +}); + +test('a visitor in a document group also gets the private documents of that group', function () { + frontendVisitorInGroups([5]); + $query = SiteContent::query()->select('site_content.id')->withoutProtected()->orderBy('site_content.id'); + + expect($query->toSql())->toContain('document_groups') + ->and(array_values(array_unique($query->pluck('id')->all())))->toBe([1, 2, 3]); +}); diff --git a/core/tests/Unit/ParserLazyBladeTest.php b/core/tests/Unit/ParserLazyBladeTest.php new file mode 100644 index 0000000000..0206e0d6c4 --- /dev/null +++ b/core/tests/Unit/ParserLazyBladeTest.php @@ -0,0 +1,108 @@ +core = (new ReflectionClass(Core::class))->newInstanceWithoutConstructor(); + $this->viewsBuilt = 0; + $this->core->singleton('view', function () { + $this->viewsBuilt++; + + return new Factory(new EngineResolver(), new FileViewFinder(new Filesystem(), [sys_get_temp_dir()]), new Dispatcher()); + }); +}); + +afterEach(fn () => resetParserInstance()); + +/** + * The finder's paths with forward slashes and no trailing slash: it resolves + * existing directories with realpath(), which uses backslashes on Windows. + */ +function viewPaths(Factory $view): array +{ + return array_map('normalizedViewPath', $view->getFinder()->getPaths()); +} + +function normalizedViewPath(string $path): string +{ + return rtrim(str_replace('\\', '/', $path), '/'); +} + +function resetParserInstance(): void +{ + (new ReflectionProperty(Parser::class, 'instance'))->setValue(null, null); +} + +test('the parser does not build its view until it is read', function () { + $parser = Parser::getInstance($this->core); + $parser->setTemplatePath('views/'); + + expect($this->viewsBuilt)->toBe(0); +}); + +test('checking whether the view is set does not build it', function () { + $parser = Parser::getInstance($this->core); + + expect(isset($parser->blade))->toBeTrue() + ->and($this->viewsBuilt)->toBe(0) + ->and($parser->blade ?? null)->toBeInstanceOf(Factory::class) + ->and($this->viewsBuilt)->toBe(1); +}); + +test('an assigned view replaces the built one', function () { + $parser = Parser::getInstance($this->core); + $view = new Factory(new EngineResolver(), new FileViewFinder(new Filesystem(), [sys_get_temp_dir()]), new Dispatcher()); + + $parser->blade = $view; + + expect($parser->blade)->toBe($view) + ->and($this->viewsBuilt)->toBe(0); + + $parser->blade = null; + + expect(isset($parser->blade))->toBeFalse() + ->and($parser->blade)->toBeNull() + ->and($this->viewsBuilt)->toBe(0); +}); + +test('the view is a copy of the factory, built once, with the template path given before', function () { + $parser = Parser::getInstance($this->core); + $parser->setTemplatePath('views/'); + + $blade = $parser->blade; + + expect($blade)->toBeInstanceOf(Factory::class) + ->and($blade)->not->toBe($this->core['view']) + ->and($parser->blade)->toBe($blade) + ->and($this->viewsBuilt)->toBe(1) + ->and(viewPaths($blade))->toBe([normalizedViewPath(EVO_BASE_PATH . 'views')]); +}); + +test('a template path set after the view is built still reaches it', function () { + $parser = Parser::getInstance($this->core); + $blade = $parser->blade; + + $parser->setTemplatePath('assets/chunks/'); + + expect($parser->blade)->toBe($blade) + ->and(viewPaths($blade))->toBe([normalizedViewPath(EVO_BASE_PATH . 'assets/chunks')]); +}); diff --git a/core/tests/Unit/UrlProcessorAliasPathTest.php b/core/tests/Unit/UrlProcessorAliasPathTest.php new file mode 100644 index 0000000000..78515816d1 --- /dev/null +++ b/core/tests/Unit/UrlProcessorAliasPathTest.php @@ -0,0 +1,85 @@ + category-042 (44) > article-1 (100); a deleted "old" (45) and + * a deleted duplicate "category-042" (46) under articles; "other" (3) at the root. + * A prefixed connection, so the table aliases of the joins are exercised. + */ +beforeEach(function () { + $capsule = new Capsule(); + $capsule->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'evo_']); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + Model::setConnectionResolver($capsule->getDatabaseManager()); + $capsule->getConnection()->getSchemaBuilder()->create('site_content', function (Blueprint $table) { + $table->increments('id'); + $table->string('alias')->default(''); + $table->integer('parent')->default(0); + $table->integer('deleted')->default(0); + $table->integer('alias_visible')->default(1); + }); + foreach ([ + ['id' => 2, 'alias' => 'articles', 'parent' => 0], + ['id' => 3, 'alias' => 'other', 'parent' => 0], + ['id' => 44, 'alias' => 'category-042', 'parent' => 2], + ['id' => 45, 'alias' => 'old', 'parent' => 2, 'deleted' => 1], + ['id' => 46, 'alias' => 'category-042', 'parent' => 3, 'deleted' => 1], + ['id' => 100, 'alias' => 'article-1', 'parent' => 44], + ] as $row) { + Capsule::table('site_content')->insert($row); + } + $this->connection = $capsule->getConnection(); +}); + +afterEach(fn () => Capsule::connection()->disconnect()); + +function aliasPathProcessor(): UrlProcessor +{ + $config = ['use_alias_path' => true]; + $core = test()->getMockBuilder(Core::class) + ->disableOriginalConstructor() + ->onlyMethods(['getConfig', 'invokeEvent']) + ->getMock(); + $core->documentListing = []; + $core->aliasListing = []; + $core->virtualDir = ''; + $core->method('getConfig')->willReturnCallback(static fn ($name, $default = null) => $config[$name] ?? $default); + $core->method('invokeEvent')->willReturn(false); + + return new UrlProcessor($core); +} + +test('an alias path is resolved with one query', function () { + $processor = aliasPathProcessor(); + $this->connection->enableQueryLog(); + + expect($processor->findIdByAliasPath('articles/category-042/article-1'))->toBe(100) + ->and($this->connection->getQueryLog())->toHaveCount(1) + ->and($processor->findIdByAliasPath('articles/category-042'))->toBe(44) + ->and($processor->findIdByAliasPath('/articles/'))->toBe(2); +}); + +test('a path that is not made of live aliases from the root is not resolved', function () { + $processor = aliasPathProcessor(); + + expect($processor->findIdByAliasPath('articles/old'))->toBeNull() + ->and($processor->findIdByAliasPath('other/category-042'))->toBeNull() + ->and($processor->findIdByAliasPath('category-042'))->toBeNull() + ->and($processor->findIdByAliasPath('articles/44'))->toBeNull() + ->and($processor->findIdByAliasPath('articles//category-042'))->toBeNull() + ->and($processor->findIdByAliasPath(''))->toBeNull(); +}); + +test('getIdFromAlias still walks aliases and ids and returns integers', function () { + $processor = aliasPathProcessor(); + + expect($processor->getIdFromAlias('articles/category-042'))->toBe(44) + ->and($processor->getIdFromAlias('articles/44/article-1'))->toBe(100) + ->and($processor->getIdFromAlias('.'))->toBe(0); +}); diff --git a/install/cli-install.php b/install/cli-install.php index 2d332541a6..56bdc54f09 100644 --- a/install/cli-install.php +++ b/install/cli-install.php @@ -49,14 +49,14 @@ class InstallEvo * with the unified ExecWithFallback::exec() which automatically tries methods in order: * exec() → passthru() → popen() → proc_open() → shell_exec() → Exception. * - * @return void + * @return bool Whether Composer ran and exited with 0. */ - protected function runComposerUpdate(string $cmd): void + protected function runComposerUpdate(string $cmd): bool { // This runs before the core bootstraps, so nothing has registered the Composer autoloader yet. if (!loadExecWithFallback()) { warning('⚠ The exec-with-fallback package is missing. Run "composer update" manually.'); - return; + return false; } $out = []; @@ -72,11 +72,15 @@ protected function runComposerUpdate(string $cmd): void if ((int) $exitCode !== 0) { warning('⚠ Composer update failed with exit code ' . (int) $exitCode . '; the dependencies shipped with the archive are kept.'); warning('⚠ Run "composer update" in the core directory once the server can reach Packagist, or install with --skipComposer=y offline.'); + return false; } } catch (\Exception $e) { info('- No command execution methods available (all disabled).'); warning('⚠ Run "composer update" manually.'); + return false; } + + return true; } public $typeInstall = ''; @@ -527,7 +531,21 @@ public function composerUpdate() escapeshellarg($workingDir) ); - $this->runComposerUpdate($cmd); + if (!$this->runComposerUpdate($cmd)) { + return; + } + + // A site needs neither the test suite's packages nor their autoloaded + // files, which would be included on every request. A separate pass: the + // update above runs Composer out of the vendor directory it rewrites, + // and resolving without them changes the order it replaces Composer's + // own dependencies in; removing packages afterwards touches none of them. + info('- Removing development packages'); + $this->runComposerUpdate(sprintf( + 'php %s install --no-interaction --no-dev --working-dir=%s', + escapeshellarg($composerBin), + escapeshellarg($workingDir) + )); } public function realInstall() diff --git a/install/stubs/files/config/database/connections/default.tpl b/install/stubs/files/config/database/connections/default.tpl index d7b29205bf..c3436c0523 100644 --- a/install/stubs/files/config/database/connections/default.tpl +++ b/install/stubs/files/config/database/connections/default.tpl @@ -12,8 +12,15 @@ return [ 'prefix' => env('DB_PREFIX', '[+table_prefix+]'), 'strict' => (bool) env('DB_STRICT', false), 'engine' => env('DB_ENGINE'[+database_engine+]), + // The DSN already names the database: a separate `use` would cost a round trip per request. + 'use_db_after_connecting' => false, 'options' => [ PDO::ATTR_STRINGIFY_FETCHES => true, PDO::ATTR_PERSISTENT => (bool) env('DB_PERSISTENT', false), - ] + ] + (in_array(env('DB_TYPE', '[+database_type+]'), ['mysql', 'mariadb'], true) ? [ + // Client-side prepares: one round trip per query instead of three (prepare, execute, close). + // Values are strings either way (ATTR_STRINGIFY_FETCHES). Quoting follows the connection + // charset, which is safe for utf8mb4/utf8/latin1; set DB_EMULATE_PREPARES=false for GBK-family charsets. + PDO::ATTR_EMULATE_PREPARES => (bool) env('DB_EMULATE_PREPARES', true), + ] : []) ];