refactor: GlobalUsages sucht über UsageQuery – Bibliothekssuche als LibraryUsageQuery

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-16 17:36:21 +02:00
co-authored by Claude Sonnet 5.5
parent 3fe83e39d3
commit 454116851e
5 changed files with 171 additions and 43 deletions
+50 -43
View File
@@ -11,11 +11,11 @@ use WP_Post;
defined('ABSPATH') || exit;
/**
* Posts (pages, Theme Builder layouts, other library entries) that show library entry $id: every
* living post whose content references it in any form Divi renders (rows(), ruling P23 – Divi 5
* instances in any JSON spelling, Divi 4 global_module shortcodes; fail closed). Candidates come
* from one query (ID, status and type), their content is parsed in bounded chunks (audit L1); the
* rights of the caller are then checked in chunks with a bounded post cache:
* Posts (pages, Theme Builder layouts, other library entries) that reference something a UsageQuery describes –
* a library entry $id (LibraryUsageQuery: every living post whose content references it in any form Divi renders,
* ruling P23 – Divi 5 instances in any JSON spelling, Divi 4 global_module shortcodes; fail closed) or, since
* Etappe 4, a preset. Candidates come from one query (ID, status and type), their content is parsed in bounded
* chunks (audit L1); the rights of the caller are then checked in chunks with a bounded post cache:
* - readable posts are listed (at most LIMIT, with title); unreadable ones only count (audit L4);
* - live posts (ruling P8a) the caller may not edit_post block a confirmed edit – by ID when
* readable, else as a count; above HARD_CAP live posts the edit is refused as a whole;
@@ -76,7 +76,48 @@ class GlobalUsages
*/
public function report(int $libraryId, bool $withBlocked = true): UsageReport
{
$rows = $this->rows($libraryId);
return $this->reportOfRows($this->rows($libraryId), $withBlocked);
}
/** report() for any reference – a library entry, a preset (Etappe 4) – described by $query. */
public function reportFor(UsageQuery $query, bool $withBlocked = true): UsageReport
{
return $this->reportOfRows($this->rowsFor($query), $withBlocked);
}
/** @return list<array{id: int, status: string, type: string}> */
public function rows(int $libraryId): array
{
return $this->rowsFor(new LibraryUsageQuery($libraryId));
}
/**
* Every post (no revision, not trashed) whose content $query matches (fail closed). Candidates come from one
* query (ID, status and type only); their content is read in chunks of CONTENT_CHUNK; beyond PARSE_CAP
* candidates the rest counts as references unread (audit L1).
*
* @return list<array{id: int, status: string, type: string}>
*/
public function rowsFor(UsageQuery $query): array
{
$candidates = $this->candidates($query);
$result = [];
foreach (array_chunk(array_slice($candidates, 0, self::PARSE_CAP), self::CONTENT_CHUNK) as $chunk) {
$contents = $this->contents(array_map(static fn (array $row): int => $row['id'], $chunk));
foreach ($chunk as $row) {
// A candidate whose content could not be read counts too (fail closed).
if (! isset($contents[$row['id']]) || $query->matches($contents[$row['id']])) {
$result[] = $row;
}
}
}
return array_merge($result, array_slice($candidates, self::PARSE_CAP));
}
/** @param list<array{id: int, status: string, type: string}> $rows */
private function reportOfRows(array $rows, bool $withBlocked): UsageReport
{
$live = count(array_filter($rows, static fn (array $row): bool => in_array($row['status'], self::LIVE, true)));
$tooMany = $withBlocked && $live > self::HARD_CAP;
$checkEdit = $withBlocked && ! $tooMany;
@@ -139,48 +180,14 @@ class GlobalUsages
return new UsageReport(count($rows), $items, $hidden, $live, $blocked, $blockedHidden, $tooMany);
}
/**
* Every post (no revision, not trashed) that shows $libraryId (ruling P23, fail closed). The
* candidates come from one query (ID, status and type only): content with "globalModule", a
* Divi 4 "global_module" (both case-insensitive) or a JSON escape of a letter (an escaped key such as globalModule
* always has one). Their content is then read in chunks of CONTENT_CHUNK and parsed by
* UsageMatcher; beyond PARSE_CAP candidates the rest counts as references unread (audit L1).
*
* @return list<array{id: int, status: string, type: string}>
*/
public function rows(int $libraryId): array
{
$candidates = $this->candidates();
$result = [];
foreach (array_chunk(array_slice($candidates, 0, self::PARSE_CAP), self::CONTENT_CHUNK) as $chunk) {
$contents = $this->contents(array_map(static fn (array $row): int => $row['id'], $chunk));
foreach ($chunk as $row) {
// A candidate whose content could not be read counts too (fail closed).
if (! isset($contents[$row['id']]) || UsageMatcher::shows($contents[$row['id']], $libraryId)) {
$result[] = $row;
}
}
}
return array_merge($result, array_slice($candidates, self::PARSE_CAP));
}
/** @return list<array{id: int, status: string, type: string}> */
private function candidates(): array
private function candidates(UsageQuery $query): array
{
$db = $this->db();
$escapedLetter = str_repeat(chr(92), 2) . 'u00[4-7][0-9a-fA-F]';
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
$rows = $db->get_results(
$db->prepare(
// Ruling P24: LOWER() keeps the LIKEs case-insensitive under any column collation.
'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE (LOWER(post_content) LIKE %s OR '
. "LOWER(post_content) LIKE %s OR post_content REGEXP %s) AND post_type <> 'revision' AND "
. self::LISTED . ' ORDER BY ID ASC',
'%' . $db->esc_like('globalmodule') . '%',
'%' . $db->esc_like('global_module') . '%',
$escapedLetter
),
'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE ' . $query->candidateCondition($db)
. " AND post_type <> 'revision' AND " . self::LISTED . ' ORDER BY ID ASC',
'ARRAY_A'
);
// phpcs:enable
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
defined('ABSPATH') || exit;
/**
* The usages of library entry $libraryId (rulings P23, P24): candidates with "globalModule", a Divi 4
* "global_module" (both case-insensitive, LOWER() under any collation) or a JSON escape of a letter (an escaped
* key such as globalModule always has one); the decision is UsageMatcher's.
*/
final class LibraryUsageQuery implements UsageQuery
{
public function __construct(private int $libraryId)
{
}
public function candidateCondition(\wpdb $db): string
{
return (string) $db->prepare(
'(LOWER(post_content) LIKE %s OR LOWER(post_content) LIKE %s OR post_content REGEXP %s)',
'%' . $db->esc_like('globalmodule') . '%',
'%' . $db->esc_like('global_module') . '%',
str_repeat(chr(92), 2) . 'u00[4-7][0-9a-fA-F]'
);
}
public function matches(string $content): bool
{
return UsageMatcher::shows($content, $this->libraryId);
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
defined('ABSPATH') || exit;
/**
* What GlobalUsages looks for (ruling P23 pattern, fail closed): a prepared SQL condition on post_content that
* keeps every post that may reference the thing (one query, ID/status/type only), and the decision on the
* content of each candidate. A library entry (LibraryUsageQuery), a preset (PresetUsageQuery, Etappe 4) …
*/
interface UsageQuery
{
/** A condition for "WHERE (…)", already passed through $db->prepare(). */
public function candidateCondition(\wpdb $db): string;
/** Whether $content references the thing; true when unsure (fail closed). */
public function matches(string $content): bool;
}
@@ -282,4 +282,34 @@ final class GlobalUsagesTest extends TestCase
self::assertFalse($report->blocks());
self::assertSame(['affected', 'affected_count'], array_keys($report->listing()));
}
public function testRowsForUsesTheConditionAndMatcherOfTheQuery(): void
{
$db = Mockery::mock(\wpdb::class)->makePartial();
$queries = [];
$db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$queries): array {
$queries[] = $sql;
return str_contains($sql, 'post_content FROM')
? [['ID' => '5', 'post_content' => 'treffer'], ['ID' => '6', 'post_content' => 'nichts']]
: [['ID' => '5', 'post_status' => 'draft', 'post_type' => 'page'],
['ID' => '6', 'post_status' => 'draft', 'post_type' => 'page']];
});
$query = new class implements \LIT\MCP\Integrations\Divi\Library\UsageQuery {
public function candidateCondition(\wpdb $db): string
{
return '(MARKER)';
}
public function matches(string $content): bool
{
return $content === 'treffer';
}
};
$rows = (new GlobalUsages($db))->rowsFor($query);
self::assertSame([['id' => 5, 'status' => 'draft', 'type' => 'page']], $rows);
self::assertStringContainsString('WHERE (MARKER) AND post_type', $queries[0]);
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
use LIT\MCP\Integrations\Divi\Library\LibraryUsageQuery;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class LibraryUsageQueryTest extends TestCase
{
public function testConditionIsTheFormerCandidateFilter(): void
{
$db = Mockery::mock(\wpdb::class);
$db->allows('esc_like')->andReturnUsing(static fn (string $text): string => $text);
$db->expects('prepare')
->with(
'(LOWER(post_content) LIKE %s OR LOWER(post_content) LIKE %s OR post_content REGEXP %s)',
'%globalmodule%',
'%global_module%',
'\\\\u00[4-7][0-9a-fA-F]'
)
->andReturn('(prepared)');
self::assertSame('(prepared)', (new LibraryUsageQuery(12))->candidateCondition($db));
}
public function testMatchesThroughTheUsageMatcher(): void
{
$query = new LibraryUsageQuery(12);
self::assertTrue($query->matches('<!-- wp:divi/section {"globalModule":"12"} /-->'));
self::assertFalse($query->matches('<!-- wp:divi/section {"globalModule":"13"} /-->'));
}
}