Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/docker/ci/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ FROM php:${PHP_VERSION}-cli
# image that lives for one workflow run does not earn the extra stage.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libpq-dev libzip-dev libicu-dev libxml2-dev libonig-dev unzip \
&& docker-php-ext-install -j"$(nproc)" pdo_mysql pdo_pgsql zip intl \
libpq-dev libzip-dev libicu-dev libxml2-dev libonig-dev \
libfreetype6-dev libjpeg62-turbo-dev libpng-dev unzip \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j"$(nproc)" pdo_mysql pdo_pgsql zip intl gd \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

Expand Down
6 changes: 6 additions & 0 deletions .github/docker/ci/install-and-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ say "Checking the data survived the update unchanged"
# inserts instead of updating doubles its table here.
php "$APP_DIR/.github/docker/ci/smoke.php" "$APP_DIR" || fail "the update changed the installed data"

# A deployed CMS must not leave the installer reachable. Keep its files out of
# the HTTP document tree before exercising the installed site, while preserving
# them under a non-routable name for this disposable CI image's diagnostics.
say "Renaming the installer directory before the HTTP smoke test"
mv "$APP_DIR/install" "$APP_DIR/install.disabled"

say "Checking the installed site answers over HTTP"
http_check "$APP_DIR"

Expand Down
52 changes: 47 additions & 5 deletions core/functions/nodes.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ function makeHTML($indent, $parent, $expandAll, $hereid = '')
'menutitle', 'parent', 'isfolder'
, 'published', 'pub_date', 'unpub_date', 'richtext', 'searchable', 'cacheable'
, 'deleted', 'type', 'template', 'templatename', 'menuindex', 'hide_from_tree', 'hidemenu', 'alias'
, 'contentType', 'privateweb', 'privatemgr'
, 'alias_visible', 'contentType', 'privateweb', 'privatemgr'
)
->leftJoin('document_groups', 'site_content.id', '=', 'document_groups.document')
->leftJoin('site_templates', 'site_content.template', '=', 'site_templates.id')
Expand Down Expand Up @@ -105,8 +105,18 @@ function makeHTML($indent, $parent, $expandAll, $hereid = '')
'menutitle', 'parent', 'isfolder'
, 'published', 'pub_date', 'unpub_date', 'richtext', 'searchable', 'cacheable'
, 'deleted', 'type', 'template', 'templatename', 'menuindex', 'hide_from_tree', 'hidemenu', 'alias'
, 'contentType', 'privateweb', 'privatemgr']);
$result = $result->get();
, 'alias_visible', 'contentType', 'privateweb', 'privatemgr']);
$result = $result->get()
->map(static fn ($item) => normalizeTreeNodeRow($item->getAttributes()));

// Every row below needs a front-end URL. When URLs are resolved through
// lazy alias lookups, reuse the aliases selected by this tree query so a
// fully expanded tree does not issue one document lookup per node merely
// to rebuild its alias path. The site cache already holds them otherwise.
$urlProcessor = \EvolutionCMS\Facades\UrlProcessor::getFacadeRoot();
if ($urlProcessor->usesLazyAliasListing()) {
$urlProcessor->primeAliasListings($result->all());
}


if ($result->count() == 0) {
Expand All @@ -118,8 +128,7 @@ function makeHTML($indent, $parent, $expandAll, $hereid = '')
} else {
$nodeNameSource = $_SESSION['tree_nodename'];
}
foreach ($result as $item) {
$row = $item->toArray();
foreach ($result as $row) {
$row['roles'] = '';
$row['nomove'] = 0;
$row['hasAccess'] = 0;
Expand Down Expand Up @@ -536,6 +545,39 @@ function makeHTML($indent, $parent, $expandAll, $hereid = '')
}
}

if (!function_exists('normalizeTreeNodeRow')) {
/**
* Give a raw tree query row the types SiteContent casts would give it,
* without running Eloquent's per-model cast/serialization path. The row
* reaches OnManagerNodePrerender / OnManagerNodeRender, so the types must
* match what toArray() produced. Null stays null; strings are untouched.
*
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
function normalizeTreeNodeRow(array $row): array
{
static $intFields = [
'id', 'parent', 'isfolder', 'published', 'pub_date', 'unpub_date', 'searchable', 'cacheable',
'deleted', 'template', 'menuindex', 'alias_visible',
];
static $boolFields = ['richtext', 'hide_from_tree', 'hidemenu', 'privateweb', 'privatemgr'];

foreach ($intFields as $field) {
if (isset($row[$field])) {
$row[$field] = (int) $row[$field];
}
}
foreach ($boolFields as $field) {
if (isset($row[$field])) {
$row[$field] = (bool) $row[$field];
}
}

return $row;
}
}

if (!function_exists('getIconInfo')) {
/**
* @param array $_style
Expand Down
71 changes: 53 additions & 18 deletions core/src/UrlProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -392,25 +392,23 @@ public static function cleanQueryString($query): string
*/
public function getAliasListing($id): ?array
{
if (isset($this->aliasListing[$id])) {
return $this->aliasListing[$id];
}
if (!isset($this->aliasListing[$id])) {
/** @var Models\SiteContent|null $query */
$query = Models\SiteContent::where('id', '=', (int)$id)->first();
if ($query === null) {
return null;
}

/** @var Models\SiteContent|null $query */
$query = Models\SiteContent::where('id', '=', (int)$id)->first();
if ($query === null) {
return null;
$this->aliasListing[$id] = [
'id' => $query->getKey(),
'alias' => $query->alias === '' ? $query->getKey() : $query->alias,
'parent' => $query->parent,
'isfolder' => $query->isfolder,
'alias_visible' => $query->alias_visible,
];
}

$this->aliasListing[$id] = [
'id' => $query->getKey(),
'alias' => $query->alias === '' ? $query->getKey() : $query->alias,
'parent' => $query->parent,
'isfolder' => $query->isfolder,
'alias_visible' => $query->alias_visible,
];

if ($query->parent <= 0) {
if (isset($this->aliasListing[$id]['path']) || $this->aliasListing[$id]['parent'] <= 0) {
return $this->aliasListing[$id];
}

Expand All @@ -419,7 +417,7 @@ public function getAliasListing($id): ?array
return $this->aliasListing[$id];
}

$tmp = $this->getAliasListing($query->parent);
$tmp = $this->getAliasListing($this->aliasListing[$id]['parent']);

if (!$tmp['alias_visible']) {
$this->aliasListing[$id]['path'] = $tmp['path'];
Expand All @@ -434,6 +432,43 @@ public function getAliasListing($id): ?array
return $this->aliasListing[$id];
}

/**
* Whether makeUrl() resolves aliases lazily through getAliasListing()
* instead of reading the full alias listing loaded from the site cache.
*/
public function usesLazyAliasListing(): bool
{
return (bool)$this->core->getConfig('aliaslistingfolder')
|| $this->core->getConfig('full_aliaslisting') == 1;
}

/**
* Prime alias metadata that has already been selected by a caller.
*
* @param iterable<array{id: int|string, alias: mixed, parent: int|string, isfolder: int|string, alias_visible: int|string}> $documents
*/
public function primeAliasListings(iterable $documents): void
{
foreach ($documents as $document) {
if (!isset($document['id'])) {
continue;
}

$id = (int) $document['id'];
if (isset($this->aliasListing[$id])) {
continue;
}

$this->aliasListing[$id] = [
'id' => $id,
'alias' => $document['alias'] === '' ? $id : $document['alias'],
'parent' => (int) $document['parent'],
'isfolder' => (int) $document['isfolder'],
'alias_visible' => (int) $document['alias_visible'],
];
}
}

/**
* @param $alias
* @return null|int
Expand Down Expand Up @@ -565,7 +600,7 @@ public function makeUrl(int $id, string $alias = '', string $args = '', string $

if ($this->core->getConfig('friendly_alias_urls')) {

if ($this->core->getConfig('aliaslistingfolder') || $this->core->getConfig('full_aliaslisting') == 1) {
if ($this->usesLazyAliasListing()) {
$al = $this->getAliasListing($id);
} else {
$al = $this->aliasListing[$id] ?? null;
Expand Down
160 changes: 160 additions & 0 deletions core/tests/Unit/UrlProcessorAliasListingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
<?php

use EvolutionCMS\Core;
use EvolutionCMS\UrlProcessor;
use Illuminate\Database\Capsule\Manager as Capsule;
use Illuminate\Database\ConnectionResolverInterface;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Schema\Blueprint;

function aliasListingProcessor(array $config = []): UrlProcessor
{
$config += [
'virtual_dir' => '',
'friendly_url_prefix' => '',
'friendly_url_suffix' => '',
'site_start' => 1,
'friendly_urls' => true,
'friendly_alias_urls' => true,
'use_alias_path' => true,
'aliaslistingfolder' => true,
'full_aliaslisting' => 1,
'make_folders' => false,
'base_url' => '',
];
$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);
}

// getAliasListing() reads through the SiteContent model, so a query is caught
// at the Eloquent connection resolver rather than at Core::getDatabase().
function forbidAliasListingQueries(): void
{
$resolver = Mockery::mock(ConnectionResolverInterface::class);
$resolver->shouldReceive('connection')
->andThrow(new LogicException('The primed alias-listing test must not query the database.'));
$resolver->shouldReceive('getDefaultConnection')->andReturn('default');
Model::setConnectionResolver($resolver);
}

function aliasListingDatabase(array $rows): Capsule
{
$capsule = new Capsule();
$capsule->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']);
$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('isfolder')->default(0);
$table->integer('alias_visible')->default(1);
$table->integer('deleted')->default(0);
$table->integer('deletedon')->default(0);
});
$capsule->table('site_content')->insert($rows);
$capsule->getConnection()->enableQueryLog();

return $capsule;
}

afterEach(function () {
Model::unsetConnectionResolver();
Mockery::close();
});

test('primed alias listings build friendly tree URLs without per-node queries', function () {
forbidAliasListingQueries();
$processor = aliasListingProcessor();

$processor->primeAliasListings([
['id' => 1, 'alias' => 'articles', 'parent' => 0, 'isfolder' => 1, 'alias_visible' => 1],
['id' => 2, 'alias' => 'category-001', 'parent' => 1, 'isfolder' => 1, 'alias_visible' => 1],
['id' => 3, 'alias' => 'article-000001', 'parent' => 2, 'isfolder' => 0, 'alias_visible' => 1],
]);

expect($processor->makeUrl(3))->toBe('articles/category-001/article-000001')
->and($processor->aliasListing[3]['path'])->toBe('articles/category-001');
});

test('primed alias listings skip a parent folder whose alias is hidden', function () {
forbidAliasListingQueries();
$processor = aliasListingProcessor();

$processor->primeAliasListings([
['id' => 1, 'alias' => 'articles', 'parent' => 0, 'isfolder' => 1, 'alias_visible' => 1],
['id' => 2, 'alias' => 'hidden-folder', 'parent' => 1, 'isfolder' => 1, 'alias_visible' => 0],
['id' => 3, 'alias' => 'article', 'parent' => 2, 'isfolder' => 0, 'alias_visible' => 1],
]);

expect($processor->makeUrl(3))->toBe('articles/article')
->and($processor->aliasListing[2]['path'])->toBe('articles');
});

test('primed alias listings keep entries that are already loaded', function () {
forbidAliasListingQueries();
$processor = aliasListingProcessor();
$processor->aliasListing[2] = [
'id' => 2, 'alias' => 'cached', 'path' => 'from-cache', 'parent' => 1, 'isfolder' => 0, 'alias_visible' => 1,
];

$processor->primeAliasListings([
['id' => 2, 'alias' => 'fresh', 'parent' => 1, 'isfolder' => 0, 'alias_visible' => 1],
]);

expect($processor->makeUrl(2))->toBe('from-cache/cached');
});

test('a parent that was not primed is loaded once and then reused', function () {
$capsule = aliasListingDatabase([
['id' => 1, 'alias' => 'articles', 'parent' => 0, 'isfolder' => 1, 'alias_visible' => 1],
]);
$processor = aliasListingProcessor();

$processor->primeAliasListings([
['id' => 2, 'alias' => 'first', 'parent' => 1, 'isfolder' => 0, 'alias_visible' => 1],
['id' => 3, 'alias' => 'second', 'parent' => 1, 'isfolder' => 0, 'alias_visible' => 1],
]);

expect($processor->makeUrl(2))->toBe('articles/first')
->and($processor->makeUrl(3))->toBe('articles/second')
->and($capsule->getConnection()->getQueryLog())->toHaveCount(1);
});

test('lazy alias listing is used only when an alias listing mode requires it', function () {
$defaults = ['aliaslistingfolder' => false, 'full_aliaslisting' => 0];

expect(aliasListingProcessor($defaults)->usesLazyAliasListing())->toBeFalse()
->and(aliasListingProcessor(['aliaslistingfolder' => true] + $defaults)->usesLazyAliasListing())->toBeTrue()
->and(aliasListingProcessor(['full_aliaslisting' => '1'] + $defaults)->usesLazyAliasListing())->toBeTrue();
});

test('tree node rows get the SiteContent cast types without touching strings or nulls', function () {
$row = normalizeTreeNodeRow([
'id' => '7', 'parent' => '1', 'isfolder' => '0', 'published' => '1', 'pub_date' => '1700000000',
'unpub_date' => null, 'searchable' => '1', 'cacheable' => '0', 'deleted' => '0', 'template' => '3',
'menuindex' => '12', 'alias_visible' => '1',
'richtext' => '1', 'hide_from_tree' => 0, 'hidemenu' => '0', 'privateweb' => 1, 'privatemgr' => null,
'type' => 'reference', 'alias' => '0012', 'pagetitle' => '42', 'contentType' => 'text/html',
'templatename' => null,
]);

expect($row)->toBe([
'id' => 7, 'parent' => 1, 'isfolder' => 0, 'published' => 1, 'pub_date' => 1700000000,
'unpub_date' => null, 'searchable' => 1, 'cacheable' => 0, 'deleted' => 0, 'template' => 3,
'menuindex' => 12, 'alias_visible' => 1,
'richtext' => true, 'hide_from_tree' => false, 'hidemenu' => false, 'privateweb' => true, 'privatemgr' => null,
'type' => 'reference', 'alias' => '0012', 'pagetitle' => '42', 'contentType' => 'text/html',
'templatename' => null,
]);
});
Loading