refactor: GlobalUsages sucht über UsageQuery – Bibliothekssuche als LibraryUsageQuery
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
3fe83e39d3
commit
454116851e
@@ -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"} /-->'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user