Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
6dd9c91
Initial plan
Copilot Nov 8, 2025
1aa88d1
Add basic wp profile queries command implementation
Copilot Nov 8, 2025
7e45434
Add hook and callback filtering support for queries command
Copilot Nov 8, 2025
60d5396
Add documentation for wp profile queries command
Copilot Nov 8, 2025
4a5f4cb
Update src/QueryLogger.php
swissspidy Nov 8, 2025
26ec9f7
Update src/Logger.php
swissspidy Nov 8, 2025
071cb94
Update features/profile.feature
swissspidy Nov 8, 2025
9e5aa60
Improve first test scenario to add custom queries
Copilot Nov 8, 2025
ec1564a
Lint fix
swissspidy Nov 10, 2025
28e1e4d
Fix PHP warnings for non-numeric values in Formatter
Copilot Nov 10, 2025
1f255b9
Address code review feedback: fix callback filtering and reset query_…
Copilot Nov 10, 2025
cc09ca2
Update src/Command.php
swissspidy Nov 10, 2025
4faf915
Merge branch 'main' into copilot/add-wp-profile-queries
swissspidy Jan 16, 2026
71b0e54
Fix test failures: add hook to callback loggers and improve Formatter…
Copilot Jan 16, 2026
7a42ddb
Use last-logger-wins for query map to show most specific hook/callback
Copilot Feb 9, 2026
91c69cf
Document query map complexity and performance characteristics
Copilot Feb 9, 2026
b4b128a
Merge branch 'main' into copilot/add-wp-profile-queries
swissspidy Mar 2, 2026
a23e1be
Fix hook filtering to skip loggers without hook property
Copilot Mar 2, 2026
44bd90a
Update src/Command.php
swissspidy Mar 2, 2026
67d57b7
Clean up some now obsolete tests
swissspidy Mar 3, 2026
1764ddd
Fix root cause of hook/callback filtering returning 0 results
Copilot Mar 3, 2026
5b79ac3
Merge branch 'main' into copilot/add-wp-profile-queries
swissspidy Mar 19, 2026
bb8a0b3
Add `@skipglobalargcheck`
swissspidy Mar 19, 2026
4e585ae
Merge branch 'main' into copilot/add-wp-profile-queries
swissspidy Mar 20, 2026
3c41965
Fix callback filter: restore stripos for substring matching after nam…
Copilot Mar 20, 2026
3eea3a3
Lint fix
swissspidy Mar 21, 2026
92a804b
Lint fix
swissspidy Mar 21, 2026
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
85 changes: 85 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,91 @@ will need to execute during the course of the request.



### wp profile queries

Profile database queries and their execution time.

~~~
wp profile queries [--url=<url>] [--hook=<hook>] [--callback=<callback>] [--fields=<fields>] [--format=<format>] [--order=<order>] [--orderby=<fields>]
~~~

Displays all database queries executed during a WordPress request,
along with their execution time and caller information. You can filter
queries to only show those executed during a specific hook or by a
specific callback.

**OPTIONS**

[--url=<url>]
Execute a request against a specified URL. Defaults to the home URL.

[--hook=<hook>]
Filter queries to only show those executed during a specific hook.

[--callback=<callback>]
Filter queries to only show those executed by a specific callback.

[--fields=<fields>]
Limit the output to specific fields.

[--format=<format>]
Render output in a particular format.
---
default: table
options:
- table
- json
- yaml
- csv
---

[--order=<order>]
Ascending or Descending order.
---
default: ASC
options:
- ASC
- DESC
---

[--orderby=<fields>]
Set orderby which field.

**EXAMPLES**

# Show all queries with their execution time
$ wp profile queries --fields=query,time
+--------------------------------------+---------+
| query | time |
+--------------------------------------+---------+
| SELECT option_value FROM wp_options | 0.0001s |
| SELECT * FROM wp_posts WHERE ID = 1 | 0.0003s |
+--------------------------------------+---------+
| total (2) | 0.0004s |
+--------------------------------------+---------+

# Show queries executed during the 'init' hook
$ wp profile queries --hook=init --fields=query,time,callback
+--------------------------------------+---------+------------------+
| query | time | callback |
+--------------------------------------+---------+------------------+
| SELECT * FROM wp_users | 0.0002s | my_init_func() |
+--------------------------------------+---------+------------------+
| total (1) | 0.0002s | |
+--------------------------------------+---------+------------------+

# Show queries executed by a specific callback
$ wp profile queries --callback='WP_Query->get_posts()' --fields=query,time
+--------------------------------------+---------+
| query | time |
+--------------------------------------+---------+
| SELECT * FROM wp_posts | 0.0004s |
+--------------------------------------+---------+
| total (1) | 0.0004s |
+--------------------------------------+---------+



### wp profile eval

Profile arbitrary code execution.
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"profile stage",
"profile hook",
"profile eval",
"profile eval-file"
"profile eval-file",
"profile queries"
],
"readme": {
"sections": [
Expand Down
121 changes: 121 additions & 0 deletions features/profile-queries.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
Feature: Profile database queries

@require-wp-4.0
Scenario: Show all database queries
Given a WP install
And a wp-content/mu-plugins/test-queries.php file:
"""
<?php
add_action( 'init', function() {
global $wpdb;
$wpdb->query( "SELECT 1 as test_query_one" );
$wpdb->query( "SELECT 2 as test_query_two" );
});
"""

When I run `wp profile queries --fields=query,time`
Then STDOUT should contain:
"""
query
"""
And STDOUT should contain:
"""
time
"""
And STDOUT should contain:
"""
SELECT 1 as test_query_one
"""
And STDOUT should contain:
"""
SELECT 2 as test_query_two
"""
And STDOUT should contain:
"""
total
"""
And STDERR should be empty

@require-wp-4.0
Scenario: Show queries with specific fields
Given a WP install

When I run `wp profile queries --fields=query,time`
Then STDOUT should contain:
"""
query
"""
And STDOUT should contain:
"""
time
"""
And STDOUT should contain:
"""
SELECT
"""
And STDERR should be empty

@require-wp-4.0
Scenario: Order queries by execution time
Given a WP install

When I run `wp profile queries --fields=time --orderby=time --order=DESC`
Then STDOUT should contain:
"""
time
"""
And STDERR should be empty

@require-wp-4.0
Scenario: Display queries in JSON format
Given a WP install

When I run `wp profile queries --format=json --fields=query,time`
Then STDOUT should contain:
"""
"query"
"""
And STDOUT should contain:
"""
"time"
"""
And STDERR should be empty

@require-wp-4.0
Scenario: Filter queries by hook
Given a WP install
And a wp-content/mu-plugins/query-test.php file:
"""
<?php
add_action( 'init', function() {
global $wpdb;
$wpdb->query( "SELECT 1 as test_query" );
});
"""

When I run `wp profile queries --hook=init --fields=query,callback`
Then STDOUT should contain:
"""
SELECT 1 as test_query
"""
And STDERR should be empty

@require-wp-4.0
Scenario: Filter queries by callback
Given a WP install
And a wp-content/mu-plugins/callback-test.php file:
"""
<?php
function my_test_callback() {
global $wpdb;
$wpdb->query( "SELECT 2 as callback_test" );
}
add_action( 'init', 'my_test_callback' );
"""

When I run `wp profile queries --callback=my_test_callback --fields=query,hook`
Then STDOUT should contain:
"""
SELECT 2 as callback_test
"""
And STDERR should be empty
1 change: 1 addition & 0 deletions features/profile.feature
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Feature: Basic profile usage
usage: wp profile eval <php-code> [--hook[=<hook>]] [--fields=<fields>] [--format=<format>] [--order=<order>] [--orderby=<fields>]
or: wp profile eval-file <file> [--hook[=<hook>]] [--fields=<fields>] [--format=<format>] [--order=<order>] [--orderby=<fields>]
or: wp profile hook [<hook>] [--all] [--spotlight] [--url=<url>] [--fields=<fields>] [--format=<format>] [--order=<order>] [--orderby=<fields>]
or: wp profile queries [--url=<url>] [--hook=<hook>] [--callback=<callback>] [--fields=<fields>] [--format=<format>] [--order=<order>] [--orderby=<fields>]
or: wp profile stage [<stage>] [--all] [--spotlight] [--url=<url>] [--fields=<fields>] [--format=<format>] [--order=<order>] [--orderby=<fields>]

See 'wp help profile <command>' for more information on a specific command.
Expand Down
161 changes: 161 additions & 0 deletions src/Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,167 @@
include $file;
}

/**
* Profile database queries and their execution time.
*
* Displays all database queries executed during a WordPress request,
* along with their execution time and caller information. You can filter
* queries to only show those executed during a specific hook or by a
* specific callback.
*
* ## OPTIONS
*
* [--url=<url>]
* : Execute a request against a specified URL. Defaults to the home URL.
*
* [--hook=<hook>]
* : Filter queries to only show those executed during a specific hook.
*
* [--callback=<callback>]
* : Filter queries to only show those executed by a specific callback.
*
* [--fields=<fields>]
* : Limit the output to specific fields.
*
* [--format=<format>]
* : Render output in a particular format.
* ---
* default: table
* options:
* - table
* - json
* - yaml
* - csv
* ---
*
* [--order=<order>]
* : Ascending or Descending order.
* ---
* default: ASC
* options:
* - ASC
* - DESC
* ---
*
* [--orderby=<fields>]
* : Set orderby which field.
*
* ## EXAMPLES
*
* # Show all queries with their execution time
* $ wp profile queries --fields=query,time
*
* # Show queries executed during the 'init' hook
* $ wp profile queries --hook=init --fields=query,time,caller
*
* # Show queries executed by a specific callback
* $ wp profile queries --callback='WP_Query->get_posts()' --fields=query,time
*
* # Show queries ordered by execution time
* $ wp profile queries --fields=query,time --orderby=time --order=DESC
*
* @when before_wp_load
*/
public function queries( $args, $assoc_args ) {
global $wpdb;

$hook = Utils\get_flag_value( $assoc_args, 'hook' );
$callback = Utils\get_flag_value( $assoc_args, 'callback' );
$order = Utils\get_flag_value( $assoc_args, 'order', 'ASC' );
$orderby = Utils\get_flag_value( $assoc_args, 'orderby', null );

// Set up profiler to track hooks and callbacks
$type = null;
$focus = null;
if ( $hook && $callback ) {
// When both are provided, profile all hooks to find the specific callback
$type = 'hook';
$focus = true;
} elseif ( $hook ) {
$type = 'hook';
$focus = $hook;
} elseif ( $callback ) {
$type = 'hook';
$focus = true; // Profile all hooks to find the specific callback
}

$profiler = new Profiler( $type, $focus );
$profiler->run();

// Build a map of query indices to hooks/callbacks
// This is O(N*Q + M) where N=loggers, Q=queries per logger, M=total queries
// For typical WordPress sites, this performs well with the array-based lookups
$query_map = array();
if ( $hook || $callback ) {
$loggers = $profiler->get_loggers();
foreach ( $loggers as $logger ) {
// Skip if filtering by callback and this logger doesn't have a callback
if ( $callback && ! isset( $logger->callback ) ) {
continue;
}

// Skip if filtering by callback and this isn't the right one
if ( $callback && isset( $logger->callback ) ) {
// Normalize callback for comparison
$normalized_callback = trim((string) $logger->callback);

Check failure on line 598 in src/Command.php

View workflow job for this annotation

GitHub Actions / code-quality / PHPCS

Expected 1 spaces before closing parenthesis; 0 found

Check failure on line 598 in src/Command.php

View workflow job for this annotation

GitHub Actions / code-quality / PHPCS

Expected a space before the type cast open parenthesis; none found

Check failure on line 598 in src/Command.php

View workflow job for this annotation

GitHub Actions / code-quality / PHPCS

Expected 1 spaces after opening parenthesis; 0 found
$normalized_filter = trim($callback);

Check failure on line 599 in src/Command.php

View workflow job for this annotation

GitHub Actions / code-quality / PHPCS

Expected 1 spaces before closing parenthesis; 0 found

Check failure on line 599 in src/Command.php

View workflow job for this annotation

GitHub Actions / code-quality / PHPCS

Expected 1 spaces after opening parenthesis; 0 found
if ( false === stripos( $normalized_callback, $normalized_filter ) ) {
continue;
}
}

// Skip if filtering for a specific hook and this isn't the right one
if ( $hook && isset( $logger->hook ) && $logger->hook !== $hook ) {
continue;
}

// Get the query indices for this logger
if ( isset( $logger->query_indices ) && ! empty( $logger->query_indices ) ) {
foreach ( $logger->query_indices as $query_index ) {
// Use last-logger-wins to get the most specific hook/callback
$query_map[ $query_index ] = array(
'hook' => isset( $logger->hook ) ? $logger->hook : null,
'callback' => isset( $logger->callback ) ? $logger->callback : null,
);
}
}
}
}

// Get all queries
$queries = array();
if ( ! empty( $wpdb->queries ) ) {
foreach ( $wpdb->queries as $index => $query_data ) {
// If filtering by hook/callback, only include queries in the map
if ( ( $hook || $callback ) && ! isset( $query_map[ $index ] ) ) {
continue;
}

$query_obj = new QueryLogger(
$query_data[0], // SQL query
$query_data[1], // Time
isset( $query_data[2] ) ? $query_data[2] : '', // Caller
isset( $query_map[ $index ]['hook'] ) ? $query_map[ $index ]['hook'] : null,
isset( $query_map[ $index ]['callback'] ) ? $query_map[ $index ]['callback'] : null
);
$queries[] = $query_obj;
}
}

// Set up fields for output
$fields = array( 'query', 'time', 'caller' );
if ( $hook && ! $callback ) {
$fields = array( 'query', 'time', 'callback', 'caller' );
} elseif ( $callback && ! $hook ) {
$fields = array( 'query', 'time', 'hook', 'caller' );
} elseif ( $hook && $callback ) {
$fields = array( 'query', 'time', 'hook', 'callback', 'caller' );
}

$formatter = new Formatter( $assoc_args, $fields );
$formatter->display_items( $queries, true, $order, $orderby );
}

/**
* Filter loggers with zero-ish values.
*
Expand Down
Loading
Loading