diff --git a/src/Integrations/Divi/Library/GlobalUsages.php b/src/Integrations/Divi/Library/GlobalUsages.php index afec7e5..f2bf06f 100644 --- a/src/Integrations/Divi/Library/GlobalUsages.php +++ b/src/Integrations/Divi/Library/GlobalUsages.php @@ -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 */ + 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 + */ + 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 $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 - */ - 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 */ - 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 diff --git a/src/Integrations/Divi/Library/LibraryUsageQuery.php b/src/Integrations/Divi/Library/LibraryUsageQuery.php new file mode 100644 index 0000000..9a34d84 --- /dev/null +++ b/src/Integrations/Divi/Library/LibraryUsageQuery.php @@ -0,0 +1,34 @@ +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); + } +} diff --git a/src/Integrations/Divi/Library/UsageQuery.php b/src/Integrations/Divi/Library/UsageQuery.php new file mode 100644 index 0000000..d208777 --- /dev/null +++ b/src/Integrations/Divi/Library/UsageQuery.php @@ -0,0 +1,21 @@ +prepare(). */ + public function candidateCondition(\wpdb $db): string; + + /** Whether $content references the thing; true when unsure (fail closed). */ + public function matches(string $content): bool; +} diff --git a/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php b/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php index c99c7ff..d493a35 100644 --- a/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php +++ b/tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php @@ -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]); + } } diff --git a/tests/Unit/Integrations/Divi/Library/LibraryUsageQueryTest.php b/tests/Unit/Integrations/Divi/Library/LibraryUsageQueryTest.php new file mode 100644 index 0000000..fe0103e --- /dev/null +++ b/tests/Unit/Integrations/Divi/Library/LibraryUsageQueryTest.php @@ -0,0 +1,36 @@ +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('')); + self::assertFalse($query->matches('')); + } +}