From 81423c3ea63531c8c9ff7591295825eabd7a5b99 Mon Sep 17 00:00:00 2001 From: flixos90 Date: Thu, 24 Aug 2023 14:40:01 +0000 Subject: [PATCH] Options, Meta APIs: Introduce `prime_options()` to load multiple options with a single database request. WordPress's `get_option()` function generally relies on making individual database requests for each option, however with the majority of options (in most cases) being autoloaded, i.e. fetched once with a single database request and then stored in (memory) cache. As part of a greater effort to reduce the amount of options that are unnecessarily autoloaded, this changeset introduces an alternative way to retrieve multiple options in a performant manner, with a single database request. This provides a reasonable alternative for e.g. plugins that use several options which only need to be loaded in a few specific screens. Specifically, this changeset introduces the following functions: * `prime_options( $options )` is the foundation to load multiple specific options with a single database request. Only options that aren't already cached (in `alloptions` or an individual cache) are retrieved from the database. * `prime_options_by_group( $option_group )` is a convenience wrapper function for the above which allows to prime all options of a specific option group (as configured via `register_setting()`). * `get_options( $options )` is another wrapper function which first primes the requested options and then returns them in an associative array, calling `get_option()` for each of them. Props mukesh27, joemcgill, costdev, olliejones. Fixes #58962. git-svn-id: https://develop.svn.wordpress.org/trunk@56445 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-includes/option.php | 111 +++++++++++++++ tests/phpunit/tests/option/getOptions.php | 91 ++++++++++++ tests/phpunit/tests/option/primeOptions.php | 130 ++++++++++++++++++ .../tests/option/primeOptionsByGroup.php | 75 ++++++++++ 4 files changed, 407 insertions(+) create mode 100644 tests/phpunit/tests/option/getOptions.php create mode 100644 tests/phpunit/tests/option/primeOptions.php create mode 100644 tests/phpunit/tests/option/primeOptionsByGroup.php diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index abeb0ae2f7..93f70f7b50 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -253,6 +253,117 @@ function get_option( $option, $default_value = false ) { return apply_filters( "option_{$option}", maybe_unserialize( $value ), $option ); } +/** + * Primes specific options into the cache with a single database query. + * + * Only options that do not already exist in cache will be primed. + * + * @since 6.4.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param array $options An array of option names to be primed. + */ +function prime_options( $options ) { + $alloptions = wp_load_alloptions(); + $cached_options = wp_cache_get_multiple( $options, 'options' ); + + // Filter options that are not in the cache. + $options_to_prime = array(); + foreach ( $options as $option ) { + if ( ( ! isset( $cached_options[ $option ] ) || ! $cached_options[ $option ] ) && ! isset( $alloptions[ $option ] ) ) { + $options_to_prime[] = $option; + } + } + + // Bail early if there are no options to be primed. + if ( empty( $options_to_prime ) ) { + return; + } + + global $wpdb; + $results = $wpdb->get_results( + $wpdb->prepare( + sprintf( + "SELECT option_name, option_value FROM $wpdb->options WHERE option_name IN (%s)", + implode( ',', array_fill( 0, count( $options_to_prime ), '%s' ) ) + ), + $options_to_prime + ) + ); + + $options_found = array(); + foreach ( $results as $result ) { + $options_found[ $result->option_name ] = maybe_unserialize( $result->option_value ); + } + wp_cache_set_multiple( $options_found, 'options' ); + + // If all options were found, no need to update `notoptions` cache. + if ( count( $options_found ) === count( $options_to_prime ) ) { + return; + } + + $options_not_found = array_diff( $options_to_prime, array_keys( $options_found ) ); + + $notoptions = wp_cache_get( 'notoptions', 'options' ); + + if ( ! is_array( $notoptions ) ) { + $notoptions = array(); + } + + // Add the options that were not found to the cache. + $update_notoptions = false; + foreach ( $options_not_found as $option_name ) { + if ( ! isset( $notoptions[ $option_name ] ) ) { + $notoptions[ $option_name ] = true; + $update_notoptions = true; + } + } + + // Only update the cache if it was modified. + if ( $update_notoptions ) { + wp_cache_set( 'notoptions', $notoptions, 'options' ); + } +} + +/** + * Primes all options registered with a specific option group. + * + * @since 6.4.0 + * + * @global array $new_allowed_options + * + * @param string $option_group The option group to prime options for. + */ +function prime_options_by_group( $option_group ) { + global $new_allowed_options; + + if ( isset( $new_allowed_options[ $option_group ] ) ) { + prime_options( $new_allowed_options[ $option_group ] ); + } +} + +/** + * Retrieves multiple options. + * + * Options are primed as necessary first in order to use a single database query at most. + * + * @since 6.4.0 + * + * @param array $options An array of option names to retrieve. + * @return array An array of key-value pairs for the requested options. + */ +function get_options( $options ) { + prime_options( $options ); + + $result = array(); + foreach ( $options as $option ) { + $result[ $option ] = get_option( $option ); + } + + return $result; +} + /** * Protects WordPress special option from being modified. * diff --git a/tests/phpunit/tests/option/getOptions.php b/tests/phpunit/tests/option/getOptions.php new file mode 100644 index 0000000000..7435dce188 --- /dev/null +++ b/tests/phpunit/tests/option/getOptions.php @@ -0,0 +1,91 @@ +assertFalse( wp_cache_get( $option, 'options' ), "$option was not deleted from the cache." ); + } + + // Call the get_options function to retrieve the options. + $options = get_options( array( 'option1', 'option2' ) ); + + // Check that options are now in the cache. + foreach ( $options_to_prime as $option ) { + $this->assertSame( wp_cache_get( $option, 'options' ), get_option( $option ), "$option was not primed." ); + } + + // Check that the retrieved options are correct. + $this->assertSame( get_option( 'option1' ), $options['option1'], 'Retrieved option1 does not match expected value.' ); + $this->assertSame( get_option( 'option2' ), $options['option2'], 'Retrieved option2 does not match expected value.' ); + } + + /** + * Tests get_options() with an empty input array. + * + * @ticket 58962 + */ + public function test_get_options_with_empty_array() { + // Call the get_options function with an empty array. + $options = get_options( array() ); + + // Make sure the result is an empty array. + $this->assertIsArray( $options, 'An array should have been returned.' ); + $this->assertEmpty( $options, 'No options should have been returned.' ); + } + + /** + * Tests get_options() with options that include some nonexistent options. + */ + public function test_get_options_with_nonexistent_options() { + // Create some options to prime. + $options_to_prime = array( + 'option1', + ); + + // Make sure options are not in cache or database initially. + $this->assertFalse( wp_cache_get( 'option1', 'options' ), 'option1 was not deleted from the cache.' ); + $this->assertFalse( wp_cache_get( 'nonexistent_option', 'options' ), 'nonexistent_option was not deleted from the cache.' ); + + // Call the get_options function with an array that includes a nonexistent option. + $options = get_options( array( 'option1', 'nonexistent_option' ) ); + + // Check that the retrieved options are correct. + $this->assertSame( get_option( 'option1' ), $options['option1'], 'Retrieved option1 does not match expected value.' ); + + // Check that options are present in the notoptions cache. + $new_notoptions = wp_cache_get( 'notoptions', 'options' ); + foreach ( $options_to_prime as $option ) { + $this->assertTrue( isset( $new_notoptions[ $option ] ), "$option was not added to the notoptions cache." ); + } + + // Check that the nonexistent option is in the result array. + $this->assertArrayHasKey( 'nonexistent_option', $options, 'Result array should not contain nonexistent_option.' ); + + $this->assertFalse( $options['nonexistent_option'], 'nonexistent_option is present in option.' ); + } +} diff --git a/tests/phpunit/tests/option/primeOptions.php b/tests/phpunit/tests/option/primeOptions.php new file mode 100644 index 0000000000..6798d33f04 --- /dev/null +++ b/tests/phpunit/tests/option/primeOptions.php @@ -0,0 +1,130 @@ +assertFalse( wp_cache_get( $option, 'options' ), "$option was not deleted from the cache." ); + } + + // Call the prime_options function to prime the options. + prime_options( $options_to_prime ); + + // Store the initial database query count. + $initial_query_count = get_num_queries(); + + // Check that options are only in the 'options' cache group. + foreach ( $options_to_prime as $option ) { + $this->assertSame( + wp_cache_get( $option, 'options' ), + get_option( $option ), + "$option was not primed to the 'options' cache group." + ); + + $this->assertFalse( + wp_cache_get( $option, 'notoptions' ), + get_option( $option ), + "$option was primed to the 'notoptions' cache group." + ); + } + + // Ensure no additional database queries were made. + $this->assertSame( + $initial_query_count, + get_num_queries(), + 'Additional database queries were made.' + ); + } + + /** + * Tests prime_options() with options that do not exist in the database. + * + * @ticket 58962 + */ + public function test_prime_options_with_nonexistent_options() { + // Create some options to prime. + $options_to_prime = array( + 'option1', + 'option2', + ); + + /* + * Set values for the options, + * clear the cache for the options, + * check options are not in cache initially. + */ + foreach ( $options_to_prime as $option ) { + $this->assertFalse( wp_cache_get( $option, 'options' ), "$option was not deleted from the cache." ); + } + + // Call the prime_options function to prime the options. + prime_options( $options_to_prime ); + + // Check that options are not in the cache or database. + foreach ( $options_to_prime as $option ) { + $this->assertFalse( wp_cache_get( $option, 'options' ), "$option was not deleted from the cache." ); + } + + // Check that options are present in the notoptions cache. + $new_notoptions = wp_cache_get( 'notoptions', 'options' ); + $this->assertIsArray( $new_notoptions, 'The notoptions cache should be an array.' ); + foreach ( $options_to_prime as $option ) { + $this->assertArrayHasKey( $option, $new_notoptions, "$option was not added to the notoptions cache." ); + } + } + + /** + * Tests prime_options() with an empty array. + * + * @ticket 58962 + */ + public function test_prime_options_with_empty_array() { + $alloptions = wp_load_alloptions(); + $notoptions = wp_cache_get( 'notoptions', 'options' ); + + prime_options( array() ); + + $this->assertSame( $alloptions, wp_cache_get( 'alloptions', 'options' ), 'The alloptions cache was modified.' ); + $this->assertSame( $notoptions, wp_cache_get( 'notoptions', 'options' ), 'The notoptions cache was modified.' ); + } + + /** + * Tests that prime_options handles an empty "notoptions" cache. + * + * @ticket 58962 + */ + public function test_prime_options_handles_empty_notoptions_cache() { + wp_cache_delete( 'notoptions', 'options' ); + + prime_options( array( 'nonexistent_option' ) ); + + $notoptions = wp_cache_get( 'notoptions', 'options' ); + $this->assertIsArray( $notoptions, 'The notoptions cache should be an array.' ); + $this->assertArrayHasKey( 'nonexistent_option', $notoptions, 'nonexistent_option was not added to notoptions.' ); + } +} diff --git a/tests/phpunit/tests/option/primeOptionsByGroup.php b/tests/phpunit/tests/option/primeOptionsByGroup.php new file mode 100644 index 0000000000..9d6819664c --- /dev/null +++ b/tests/phpunit/tests/option/primeOptionsByGroup.php @@ -0,0 +1,75 @@ + array( + 'option1', + 'option2', + ), + 'group2' => array( + 'option3', + ), + ); + + $options_to_prime = array( + 'option1', + 'option2', + 'option3', + ); + + /* + * Set values for the options, + * clear the cache for the options, + * check options are not in cache initially. + */ + foreach ( $options_to_prime as $option ) { + update_option( $option, "value_$option", false ); + wp_cache_delete( $option, 'options' ); + $this->assertFalse( wp_cache_get( $option, 'options' ), "$option was not deleted from the cache." ); + } + + // Call the prime_options_by_group function to prime the options. + prime_options_by_group( 'group1' ); + + // Check that options are now in the cache. + $this->assertSame( get_option( 'option1' ), wp_cache_get( 'option1', 'options' ), 'option1 was not primed.' ); + $this->assertSame( get_option( 'option2' ), wp_cache_get( 'option2', 'options' ), 'option2 was not primed.' ); + + // Make sure option3 is still not in cache. + $this->assertFalse( wp_cache_get( 'option3', 'options' ), 'option3 was not deleted from the cache.' ); + } + + /** + * Tests prime_options_by_group() with a nonexistent option group. + * + * @ticket 58962 + */ + public function test_prime_options_by_group_with_nonexistent_group() { + // Make sure options are not in cache or database initially. + $this->assertFalse( wp_cache_get( 'option1', 'options' ), 'option1 was not deleted from the cache.' ); + $this->assertFalse( wp_cache_get( 'option2', 'options' ), 'option2 was not deleted from the cache.' ); + + // Call the prime_options_by_group function with a nonexistent group. + prime_options_by_group( 'nonexistent_group' ); + + // Check that options are still not in the cache or database. + $this->assertFalse( wp_cache_get( 'option1', 'options' ), 'option1 was not deleted from the cache.' ); + $this->assertFalse( wp_cache_get( 'option2', 'options' ), 'option2 was not deleted from the cache.' ); + } +}