diff --git a/docs/plugins/wpgraphql-logging/how-to/update-log-store-service/index.md b/docs/plugins/wpgraphql-logging/how-to/update-log-store-service/index.md
index 20128e19d..8d002ccb4 100644
--- a/docs/plugins/wpgraphql-logging/how-to/update-log-store-service/index.md
+++ b/docs/plugins/wpgraphql-logging/how-to/update-log-store-service/index.md
@@ -104,6 +104,20 @@ add_action( 'plugins_loaded', function() {
With this in place, all logs from WPGraphQL Logging will be routed through your `MyFileLogService` and saved to a file instead of the database.
+## Supporting search in the admin
+
+The search box on GraphQL Logs -> All Logs passes the search term to `find_entities_by_where()` and `count_entities_by_where()` as a where condition with the pseudo-column `LogServiceInterface::SEARCH_COLUMN`:
+
+```php
+[
+ 'column' => LogServiceInterface::SEARCH_COLUMN, // 'search'
+ 'operator' => 'LIKE',
+ 'value' => 'GetPosts', // The raw search term, not a LIKE pattern.
+]
+```
+
+Your service decides which fields to match the term against. If it doesn't support search, it can ignore this condition.
+
## Contributing
We welcome and appreciate contributions from the community. If you'd like to help improve this documentation, please check out our [Contributing Guide](https://github.com/wpengine/hwptoolkit/blob/main/CONTRIBUTING.md) for more details on how to get started.
diff --git a/docs/plugins/wpgraphql-logging/index.md b/docs/plugins/wpgraphql-logging/index.md
index 0231ad998..62e9c9a34 100644
--- a/docs/plugins/wpgraphql-logging/index.md
+++ b/docs/plugins/wpgraphql-logging/index.md
@@ -38,7 +38,7 @@ The plugin provides comprehensive data management capabilities, allowing you to
### Admin UI
-The plugin delivers a admin UI with some filtering of logs around dates or level and the ability to download logs in CSV format.
+The plugin delivers a admin UI with a search box, some filtering of logs around dates or level and the ability to download logs in CSV format.
***
@@ -142,6 +142,14 @@ ID,Date,Level,"Level Name",Message,Channel,Query,Context,Extra
5293,"2025-10-06 15:41:34",200,INFO,"WPGraphQL Response",wpgraphql_logging,"{ posts(first: 10) ...""memory_peak_usage"":""18 MB""}"
```
+### Searching Logs
+
+Use the search box to find logs by their message (for example `WPGraphQL Response with Errors`) or by the text of the GraphQL query, such as an operation name like `GetPosts`. The search is case-insensitive, and it works together with the filters, sorting and pagination.
+
+To stay fast on large log tables, the search only looks at the message and the `query` in the log context, not the variables, request or response. A `LIKE '%term%'` search can't use an index, so combine it with a date or level filter when you have many logs.
+
+Developers can change or remove the search condition with the `wpgraphql_logging_logs_table_where_clauses` filter.
+
### Filtering Logs
You can also filter logs with the following filters:
diff --git a/plugins/wpgraphql-logging/src/Admin/View/List/ListTable.php b/plugins/wpgraphql-logging/src/Admin/View/List/ListTable.php
index cd7e9028d..9b6afbb13 100644
--- a/plugins/wpgraphql-logging/src/Admin/View/List/ListTable.php
+++ b/plugins/wpgraphql-logging/src/Admin/View/List/ListTable.php
@@ -36,6 +36,13 @@ class ListTable extends WP_List_Table {
*/
public const DEFAULT_PER_PAGE = 20;
+ /**
+ * Maximum length of the search term.
+ *
+ * @var int
+ */
+ public const MAX_SEARCH_LENGTH = 200;
+
/**
* Constructor.
*
@@ -164,12 +171,11 @@ public function process_bulk_action(): void {
}
$preserved_filters = [];
- $filter_keys = [ 'level_filter', 'start_date', 'end_date' ];
- foreach ( $filter_keys as $key ) {
+ foreach ( ViewLogsPage::FILTER_KEYS as $key ) {
$value = isset( $_REQUEST[ $key ] ) && is_string( $_REQUEST[ $key ] ) ? sanitize_text_field( wp_unslash( $_REQUEST[ $key ] ) ) : null;
if ( null !== $value && '' !== $value ) {
- $preserved_filters[ $key ] = $value;
+ $preserved_filters[ $key ] = rawurlencode( $value );
}
}
@@ -467,6 +473,17 @@ protected function process_where(array $request): array {
];
}
+ if ( isset( $request['s'] ) && is_string( $request['s'] ) ) {
+ $search = mb_substr( sanitize_text_field( wp_unslash( $request['s'] ) ), 0, self::MAX_SEARCH_LENGTH );
+ if ( '' !== $search ) {
+ $where_clauses[] = [
+ 'column' => LogServiceInterface::SEARCH_COLUMN,
+ 'operator' => 'LIKE',
+ 'value' => $search,
+ ];
+ }
+ }
+
// Allow developers to modify the where clauses.
return apply_filters( 'wpgraphql_logging_logs_table_where_clauses', $where_clauses, $request );
}
diff --git a/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerFilters.php b/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerFilters.php
index 25fb6a7fd..b466176ac 100644
--- a/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerFilters.php
+++ b/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerFilters.php
@@ -13,9 +13,10 @@
exit;
}
-$wpgraphql_logging_current_level = isset( $_POST['level_filter'] ) ? sanitize_text_field( wp_unslash( $_POST['level_filter'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
-$wpgraphql_logging_current_start_date = isset( $_POST['start_date'] ) ? sanitize_text_field( wp_unslash( $_POST['start_date'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
-$wpgraphql_logging_current_end_date = isset( $_POST['end_date'] ) ? sanitize_text_field( wp_unslash( $_POST['end_date'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
+// Filters are submitted by POST and redirected to GET, so read them from the request.
+$wpgraphql_logging_current_level = isset( $_REQUEST['level_filter'] ) && is_string( $_REQUEST['level_filter'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['level_filter'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+$wpgraphql_logging_current_start_date = isset( $_REQUEST['start_date'] ) && is_string( $_REQUEST['start_date'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['start_date'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+$wpgraphql_logging_current_end_date = isset( $_REQUEST['end_date'] ) && is_string( $_REQUEST['end_date'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['end_date'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
// Currently only use info and error.
$wpgraphql_logging_log_levels = [ 'info', 'error' ];
@@ -23,6 +24,8 @@
?>
+
+
prepare_items(); ?>
+ search_box( __( 'Search Logs', 'wpgraphql-logging' ), 'wpgraphql-logging' ); ?>
display(); ?>
diff --git a/plugins/wpgraphql-logging/src/Admin/ViewLogsPage.php b/plugins/wpgraphql-logging/src/Admin/ViewLogsPage.php
index 78c48c4b0..93f9f26cc 100644
--- a/plugins/wpgraphql-logging/src/Admin/ViewLogsPage.php
+++ b/plugins/wpgraphql-logging/src/Admin/ViewLogsPage.php
@@ -38,6 +38,13 @@ class ViewLogsPage {
*/
public const ADMIN_PAGE_DOWNLOAD_NONCE = 'wp_graphql_logging_download';
+ /**
+ * The request keys for the search and filters of the logs list.
+ *
+ * @var array
+ */
+ public const FILTER_KEYS = [ 'start_date', 'end_date', 'level_filter', 's' ];
+
/**
* The hook suffix for the admin page.
*
@@ -229,6 +236,11 @@ public function process_filters_redirect(): void {
return;
}
+ // The filters share a form with the bulk actions, which ListTable handles itself.
+ if ( $this->is_bulk_action_request() ) {
+ return;
+ }
+
$redirect_url = $this->get_redirect_url();
wp_safe_redirect( $redirect_url );
@@ -243,13 +255,7 @@ public function process_filters_redirect(): void {
public function get_redirect_url(): string {
$redirect_url = menu_page_url( self::ADMIN_PAGE_SLUG, false );
- $possible_filters = [
- 'start_date',
- 'end_date',
- 'level_filter',
- 'orderby',
- 'order',
- ];
+ $possible_filters = [ ...self::FILTER_KEYS, 'orderby', 'order' ];
$filters = [];
foreach ( $possible_filters as $key ) {
$value = $this->get_post_value( $key );
@@ -258,13 +264,54 @@ public function get_redirect_url(): string {
}
}
- $redirect_url = add_query_arg( array_filter( $filters, static function ( $value ) {
+ // Keep the typed page number only when the search and filters did not change.
+ $paged = $this->get_post_value( 'paged' );
+ if ( null !== $paged && absint( $paged ) > 1 && ! $this->filters_changed( $filters ) ) {
+ $filters['paged'] = (string) absint( $paged );
+ }
+
+ // add_query_arg() does not encode values, and a search term can contain "&", "#" or "{".
+ $redirect_url = add_query_arg( array_map( 'rawurlencode', array_filter( $filters, static function ( $value ) {
return '' !== $value;
- } ), $redirect_url );
+ } ) ), $redirect_url );
return (string) apply_filters( 'wpgraphql_logging_filter_redirect_url', $redirect_url, $filters );
}
+ /**
+ * Whether the submitted search or filters differ from the ones in the current URL.
+ *
+ * @param array $filters The submitted filters.
+ *
+ * @phpcs:disable WordPress.Security.NonceVerification.Recommended
+ *
+ * @return bool True if any search or filter value changed.
+ */
+ protected function filters_changed(array $filters): bool {
+ foreach ( self::FILTER_KEYS as $key ) {
+ $current = isset( $_GET[ $key ] ) && is_string( $_GET[ $key ] ) ? sanitize_text_field( wp_unslash( $_GET[ $key ] ) ) : '';
+ if ( ( $filters[ $key ] ?? '' ) !== $current ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Whether the submitted form is requesting a bulk action.
+ *
+ * @return bool True if a bulk action other than the "-1" placeholder was selected.
+ */
+ protected function is_bulk_action_request(): bool {
+ foreach ( [ 'action', 'action2' ] as $key ) {
+ $value = $this->get_post_value( $key );
+ if ( null !== $value && '-1' !== $value ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
/**
* Retrieves and sanitizes a value from the $_POST superglobal.
*
diff --git a/plugins/wpgraphql-logging/src/Logger/Api/LogServiceInterface.php b/plugins/wpgraphql-logging/src/Logger/Api/LogServiceInterface.php
index cd636a842..1e881bec7 100644
--- a/plugins/wpgraphql-logging/src/Logger/Api/LogServiceInterface.php
+++ b/plugins/wpgraphql-logging/src/Logger/Api/LogServiceInterface.php
@@ -14,6 +14,17 @@
* @since 0.0.1
*/
interface LogServiceInterface {
+ /**
+ * Pseudo-column used in where conditions for a free-text search.
+ *
+ * A condition of `[ 'column' => self::SEARCH_COLUMN, 'operator' => 'LIKE', 'value' => $term ]`
+ * asks the service to match `$term` against whichever fields it considers searchable.
+ * Services that do not support search may ignore it.
+ *
+ * @var string
+ */
+ public const SEARCH_COLUMN = 'search';
+
/**
* Activates the log service.
*/
diff --git a/plugins/wpgraphql-logging/src/Logger/Database/WordPressDatabaseLogService.php b/plugins/wpgraphql-logging/src/Logger/Database/WordPressDatabaseLogService.php
index b5feca4a8..12d833e90 100644
--- a/plugins/wpgraphql-logging/src/Logger/Database/WordPressDatabaseLogService.php
+++ b/plugins/wpgraphql-logging/src/Logger/Database/WordPressDatabaseLogService.php
@@ -250,6 +250,10 @@ protected function prepare_sql(string $sql, array $where_conditions): string {
if ( '' === $column ) {
continue;
}
+ if ( self::SEARCH_COLUMN === $column ) {
+ $where_clauses[] = $this->prepare_search_clause( $condition );
+ continue;
+ }
if ( ! in_array( $column, $allowed_columns, true ) ) {
continue;
}
@@ -264,6 +268,7 @@ protected function prepare_sql(string $sql, array $where_conditions): string {
$this->where_values[] = $value;
}
+ $where_clauses = array_filter( $where_clauses, static fn( string $clause ): bool => '' !== $clause );
if ( ! empty( $where_clauses ) ) {
$sql .= ' WHERE ' . implode( ' AND ', $where_clauses );
}
@@ -271,6 +276,42 @@ protected function prepare_sql(string $sql, array $where_conditions): string {
return $sql;
}
+ /**
+ * Builds the where clause for a free-text search condition.
+ *
+ * Only the message column and the top-level `query` key of the context JSON are searched.
+ * A leading-wildcard LIKE cannot use an index, so the set is kept small on purpose: the
+ * rest of the context (variables, request, response) can be large and would match noise.
+ * Filtering by date or level narrows the rows first via the existing indexes.
+ *
+ * @param array{column: string, operator: string, value: string} $condition The search condition.
+ *
+ * @return string The where clause, or an empty string if the condition is invalid.
+ */
+ protected function prepare_search_clause(array $condition): string {
+ global $wpdb;
+
+ if ( 'LIKE' !== $condition['operator'] || ! is_string( $condition['value'] ) ) {
+ return '';
+ }
+
+ $term = trim( $condition['value'] );
+ if ( '' === $term ) {
+ return '';
+ }
+
+ $like = '%' . $wpdb->esc_like( $term ) . '%';
+
+ // JSON_UNQUOTE() returns a binary collation on MySQL, so compare lowercase to stay case-insensitive.
+ $this->where_values[] = 'message';
+ $this->where_values[] = $like;
+ $this->where_values[] = 'context';
+ $this->where_values[] = '$.query';
+ $this->where_values[] = $like;
+
+ return '(%i LIKE %s OR LOWER(JSON_UNQUOTE(JSON_EXTRACT(%i, %s))) LIKE LOWER(%s))';
+ }
+
/**
* The safe operators.
*
diff --git a/plugins/wpgraphql-logging/tests/e2e/specs/search-logs.spec.js b/plugins/wpgraphql-logging/tests/e2e/specs/search-logs.spec.js
new file mode 100644
index 000000000..35b6a4a5b
--- /dev/null
+++ b/plugins/wpgraphql-logging/tests/e2e/specs/search-logs.spec.js
@@ -0,0 +1,80 @@
+import { expect, test } from "@wordpress/e2e-test-utils-playwright";
+import {
+ goToLoggingSettingsPage,
+ goToLogsListPage,
+ configureLogging,
+ executeGraphQLQuery,
+ resetPluginSettings,
+} from "../utils";
+import { GET_POSTS_QUERY } from "../constants";
+
+const GET_SETTINGS_QUERY = `
+ query GetSiteSettings {
+ generalSettings {
+ title
+ }
+ }
+`;
+
+test.describe("Search Logs", () => {
+ test.beforeEach(async ({ admin, page, request }) => {
+ await resetPluginSettings(admin);
+
+ await goToLoggingSettingsPage(admin);
+ await configureLogging(page, {
+ enabled: true,
+ dataSampling: "100",
+ eventLogSelection: ["graphql_request_results"],
+ });
+
+ expect((await executeGraphQLQuery(request, GET_POSTS_QUERY)).ok()).toBeTruthy();
+ expect((await executeGraphQLQuery(request, GET_SETTINGS_QUERY)).ok()).toBeTruthy();
+ });
+
+ test("finds logs by query text", async ({ page, admin }) => {
+ await goToLogsListPage(admin);
+
+ const rows = page.locator("#the-list tr");
+ await expect(rows.filter({ hasText: "GetPosts" })).toHaveCount(1);
+ await expect(rows.filter({ hasText: "GetSiteSettings" })).toHaveCount(1);
+
+ const searchInput = page.locator("#wpgraphql-logging-search-input");
+ await searchInput.fill("getsitesettings");
+ await searchInput.press("Enter");
+
+ // The search is redirected to GET so it stays in the pagination and sort links.
+ await expect(page).toHaveURL(/[?&]s=getsitesettings(&|$)/);
+ await expect(searchInput).toHaveValue("getsitesettings");
+ await expect(rows.filter({ hasText: "GetSiteSettings" })).toHaveCount(1);
+ await expect(rows.filter({ hasText: "GetPosts" })).toHaveCount(0);
+ });
+
+ test("combines search with the level filter", async ({ page, admin }) => {
+ await goToLogsListPage(admin);
+
+ await page.locator("#wpgraphql-logging-search-input").fill("GetPosts");
+ await page.locator('select[name="level_filter"]').selectOption("error");
+ await page.getByRole("button", { name: "Filter", exact: true }).click();
+
+ await expect(page).toHaveURL(/level_filter=error/);
+ await expect(page).toHaveURL(/[?&]s=GetPosts(&|$)/);
+ await expect(page.locator('select[name="level_filter"]')).toHaveValue("error");
+ await expect(
+ page.locator('td.colspanchange:has-text("No items found.")')
+ ).toBeVisible();
+ });
+
+ test("shows no results for an unmatched search", async ({ page, admin }) => {
+ await goToLogsListPage(admin);
+
+ const searchInput = page.locator("#wpgraphql-logging-search-input");
+ await searchInput.fill("NoSuchOperation");
+ await searchInput.press("Enter");
+
+ await expect(
+ page.locator('td.colspanchange:has-text("No items found.")')
+ ).toBeVisible();
+ // The search box stays visible so the search can be changed.
+ await expect(searchInput).toHaveValue("NoSuchOperation");
+ });
+});
diff --git a/plugins/wpgraphql-logging/tests/wpunit/Admin/View/List/ListTableTest.php b/plugins/wpgraphql-logging/tests/wpunit/Admin/View/List/ListTableTest.php
index 9e9403c04..d1627db4a 100644
--- a/plugins/wpgraphql-logging/tests/wpunit/Admin/View/List/ListTableTest.php
+++ b/plugins/wpgraphql-logging/tests/wpunit/Admin/View/List/ListTableTest.php
@@ -6,6 +6,7 @@
use WPGraphQL\Logging\Admin\View\List\ListTable;
+use WPGraphQL\Logging\Logger\Api\LogServiceInterface;
use WPGraphQL\Logging\Logger\Database\WordPressDatabaseEntity;
use WPGraphQL\Logging\Logger\Database\WordPressDatabaseLogService;
use Codeception\TestCase\WPTestCase;
@@ -303,6 +304,63 @@ public function test_process_where_handles_date_filters(): void {
], $result);
}
+ public function test_process_where_handles_search(): void {
+ $reflection = new \ReflectionClass($this->list_table);
+ $method = $reflection->getMethod('process_where');
+ $method->setAccessible(true);
+
+ $request = [
+ 'level_filter' => 'error',
+ 's' => ' GetPosts ',
+ ];
+ $result = $method->invoke($this->list_table, $request);
+
+ $this->assertSame([
+ [
+ 'column' => 'level_name',
+ 'operator' => '=',
+ 'value' => 'error',
+ ],
+ [
+ 'column' => LogServiceInterface::SEARCH_COLUMN,
+ 'operator' => 'LIKE',
+ 'value' => 'GetPosts',
+ ],
+ ], $result);
+ }
+
+ public function test_process_where_ignores_empty_and_caps_long_search(): void {
+ $reflection = new \ReflectionClass($this->list_table);
+ $method = $reflection->getMethod('process_where');
+ $method->setAccessible(true);
+
+ $this->assertSame([], $method->invoke($this->list_table, ['s' => ' ']));
+ $this->assertSame([], $method->invoke($this->list_table, ['s' => ['array']]));
+
+ $result = $method->invoke($this->list_table, ['s' => str_repeat('a', ListTable::MAX_SEARCH_LENGTH + 50)]);
+ $this->assertSame(ListTable::MAX_SEARCH_LENGTH, strlen($result[0]['value']));
+ }
+
+ public function test_prepare_items_passes_search_to_count_and_find(): void {
+ $expected = [
+ [
+ 'column' => LogServiceInterface::SEARCH_COLUMN,
+ 'operator' => 'LIKE',
+ 'value' => 'GetPosts',
+ ],
+ ];
+ $this->log_service->shouldReceive('count_entities_by_where')->once()->with($expected)->andReturn(1);
+ $this->log_service->shouldReceive('find_entities_by_where')->once()->with(Mockery::on(static function ($args) use ($expected) {
+ return $args['where'] === $expected;
+ }))->andReturn([]);
+
+ $_REQUEST = ['s' => 'GetPosts'];
+ $this->list_table->prepare_items();
+ $_REQUEST = [];
+
+ $this->assertSame([], $this->list_table->items);
+ }
+
public function test_prepare_items_sets_pagination_args(): void {
$this->log_service->shouldReceive('count_entities_by_where')->andReturn(50);
$this->log_service->shouldReceive('find_entities_by_where')->andReturn([]);
diff --git a/plugins/wpgraphql-logging/tests/wpunit/Admin/View/ViewLogsPageTest.php b/plugins/wpgraphql-logging/tests/wpunit/Admin/View/ViewLogsPageTest.php
index a509575d8..dc2cd6a55 100644
--- a/plugins/wpgraphql-logging/tests/wpunit/Admin/View/ViewLogsPageTest.php
+++ b/plugins/wpgraphql-logging/tests/wpunit/Admin/View/ViewLogsPageTest.php
@@ -189,11 +189,93 @@ public function test_get_redirect_url_constructs_correct_url(): void {
$this->assertStringNotContainsString('level_filter', $url);
$this->assertEquals(
menu_page_url(ViewLogsPage::ADMIN_PAGE_SLUG, false) .
- '&start_date=2025-01-01 00:00:00&end_date=2025-12-31 23:59:59&orderby=id&order=ASC',
+ '&start_date=2025-01-01%2000%3A00%3A00&end_date=2025-12-31%2023%3A59%3A59&orderby=id&order=ASC',
$url
);
}
+ public function test_get_redirect_url_includes_search(): void {
+ $this->set_as_admin();
+ $instance = ViewLogsPage::init();
+
+ $_POST['s'] = 'GetPosts';
+ $_POST['level_filter'] = 'error';
+
+ $url = $instance->get_redirect_url();
+ $this->assertEquals(
+ menu_page_url(ViewLogsPage::ADMIN_PAGE_SLUG, false) . '&level_filter=error&s=GetPosts',
+ $url
+ );
+
+ unset($_POST['s'], $_POST['level_filter']);
+ }
+
+ public function test_get_redirect_url_encodes_search(): void {
+ $this->set_as_admin();
+ $instance = ViewLogsPage::init();
+
+ $_POST['s'] = 'query GetPost($id: ID!) { post(id: $id) { title } } & #more';
+
+ $url = $instance->get_redirect_url();
+ $query = [];
+ parse_str((string) wp_parse_url($url, PHP_URL_QUERY), $query);
+ $this->assertSame($_POST['s'], $query['s']);
+ $this->assertSame($url, wp_sanitize_redirect($url), 'The URL should survive wp_safe_redirect() unchanged.');
+
+ unset($_POST['s']);
+ }
+
+ public function test_get_redirect_url_keeps_page_only_when_filters_unchanged(): void {
+ $this->set_as_admin();
+ $instance = ViewLogsPage::init();
+
+ // Typing a page number keeps the current search.
+ $_GET['s'] = 'GetPosts';
+ $_POST['s'] = 'GetPosts';
+ $_POST['paged'] = '3';
+ $this->assertStringContainsString('paged=3', $instance->get_redirect_url());
+
+ // A new search starts from the first page.
+ $_POST['s'] = 'GetMenus';
+ $this->assertStringNotContainsString('paged', $instance->get_redirect_url());
+
+ unset($_GET['s'], $_POST['s'], $_POST['paged']);
+ }
+
+ public function test_process_filters_redirect_skips_bulk_actions(): void {
+ $this->set_as_admin();
+ $instance = ViewLogsPage::init();
+
+ $_POST['wpgraphql_logging_nonce'] = wp_create_nonce('wpgraphql_logging_filter');
+ $_POST['action'] = 'delete';
+ $_POST['action2'] = '-1';
+
+ // Would redirect and exit if the bulk action was not detected.
+ $instance->process_filters_redirect();
+ $this->assertTrue(true);
+
+ unset($_POST['wpgraphql_logging_nonce'], $_POST['action'], $_POST['action2']);
+ }
+
+ public function test_is_bulk_action_request(): void {
+ $this->set_as_admin();
+ $instance = ViewLogsPage::init();
+ $method = (new \ReflectionClass($instance))->getMethod('is_bulk_action_request');
+ $method->setAccessible(true);
+
+ // Both dropdowns left on the placeholder, as when filtering or searching.
+ $_POST['action'] = '-1';
+ $_POST['action2'] = '-1';
+ $this->assertFalse($method->invoke($instance));
+
+ // The bottom dropdown is used.
+ $_POST['action2'] = 'delete_all';
+ $this->assertTrue($method->invoke($instance));
+
+ unset($_POST['action'], $_POST['action2']);
+ $this->assertFalse($method->invoke($instance));
+ }
+
public function test_process_log_download_dies_without_nonce(): void {
$this->set_as_admin();
$instance = ViewLogsPage::init();
diff --git a/plugins/wpgraphql-logging/tests/wpunit/Logger/Database/WordPressDatabaseLogServiceTest.php b/plugins/wpgraphql-logging/tests/wpunit/Logger/Database/WordPressDatabaseLogServiceTest.php
index 92ef409de..4f7398765 100644
--- a/plugins/wpgraphql-logging/tests/wpunit/Logger/Database/WordPressDatabaseLogServiceTest.php
+++ b/plugins/wpgraphql-logging/tests/wpunit/Logger/Database/WordPressDatabaseLogServiceTest.php
@@ -8,6 +8,7 @@
use WPGraphQL\Logging\Logger\Database\WordPressDatabaseLogService;
use WPGraphQL\Logging\Logger\Database\WordPressDatabaseEntity;
use WPGraphQL\Logging\Logger\Api\LogEntityInterface;
+use WPGraphQL\Logging\Logger\Api\LogServiceInterface;
/**
* Test for the WordPressDatabaseLogService
@@ -184,4 +185,87 @@ public function test_delete_log_older_than(): void
$logs = $this->log_service->find_entities_by_where();
$this->assertCount(0, $logs);
}
+
+ public function insert_search_data(): void
+ {
+ $this->log_service->create_log_entity('wpgraphql_logging', 200, 'INFO', 'WPGraphQL Response', ['query' => 'query GetPosts { posts { nodes { title } } }']);
+ $this->log_service->create_log_entity('wpgraphql_logging', 400, 'ERROR', 'WPGraphQL Response with Errors', ['query' => 'query GetMenus { menus { nodes { name } } }']);
+ $this->log_service->create_log_entity('wpgraphql_logging', 200, 'INFO', 'Discount 100% applied', ['variables' => ['name' => 'GetPosts']]);
+ }
+
+ private function search(string $term, array $where = []): array
+ {
+ $where[] = [
+ 'column' => LogServiceInterface::SEARCH_COLUMN,
+ 'operator' => 'LIKE',
+ 'value' => $term,
+ ];
+ return $this->log_service->find_entities_by_where(['where' => $where]);
+ }
+
+ public function test_search_matches_message(): void
+ {
+ $this->insert_search_data();
+ $logs = $this->search('with Errors');
+ $this->assertCount(1, $logs);
+ $this->assertEquals('WPGraphQL Response with Errors', $logs[0]->get_message());
+ }
+
+ public function test_search_matches_query_case_insensitively(): void
+ {
+ $this->insert_search_data();
+ $logs = $this->search('getposts');
+ $this->assertCount(1, $logs, 'Only the query should match, not variables in the context.');
+ $this->assertEquals('WPGraphQL Response', $logs[0]->get_message());
+ }
+
+ public function test_search_escapes_like_wildcards(): void
+ {
+ $this->insert_search_data();
+ $this->assertCount(1, $this->search('100%'));
+ $this->assertCount(0, $this->search('_iscount'));
+ }
+
+ public function test_search_matches_quotes_and_backslashes_in_query(): void
+ {
+ $this->log_service->create_log_entity('wpgraphql_logging', 200, 'INFO', 'WPGraphQL Response', ['query' => 'query { posts(where: {search: "it\'s a \\\\ test"}) { nodes { id } } }']);
+
+ // The query is unquoted from the JSON before matching, so its quotes and backslashes are not JSON-escaped.
+ $this->assertCount(1, $this->search('search: "it\'s'));
+ $this->assertCount(1, $this->search('a \\\\ test'));
+ $this->assertCount(0, $this->search('a \\\\\\\\ test'));
+ }
+
+ public function test_search_composes_with_other_conditions_and_count(): void
+ {
+ $this->insert_search_data();
+ $level = [
+ 'column' => 'level_name',
+ 'operator' => '=',
+ 'value' => 'ERROR',
+ ];
+ $this->assertCount(2, $this->search('WPGraphQL Response'));
+ $logs = $this->search('WPGraphQL Response', [$level]);
+ $this->assertCount(1, $logs);
+ $this->assertEquals('ERROR', $logs[0]->get_level_name());
+
+ $where = [
+ $level,
+ ['column' => LogServiceInterface::SEARCH_COLUMN, 'operator' => 'LIKE', 'value' => 'menus'],
+ ];
+ $this->assertEquals(1, $this->log_service->count_entities_by_where($where));
+ $where[1]['value'] = 'GetPosts';
+ $this->assertEquals(0, $this->log_service->count_entities_by_where($where));
+ }
+
+ public function test_search_ignores_empty_term_and_unsupported_operator(): void
+ {
+ $this->insert_search_data();
+ $this->assertCount(3, $this->search(' '));
+
+ $logs = $this->log_service->find_entities_by_where(['where' => [
+ ['column' => LogServiceInterface::SEARCH_COLUMN, 'operator' => '=', 'value' => 'GetPosts'],
+ ]]);
+ $this->assertCount(3, $logs);
+ }
}