Options, Meta APIs: Improve logic to avoid unnecessary database writes in update_option().

Prior to this change, a strict comparison between the old and new database value could lead to a false negative, since database values are generally stored as strings. For example, passing an integer to `update_option()` would almost always result in an update given any existing database value for that option would be that number cast to a string.

This changeset adjusts the logic to perform an intentional "loose-y" comparison by casting the values to strings. Extensive coverage previously added in [56648] provides additional confidence that this does not introduce any backward compatibility issues.

Props mukesh27, costdev, spacedmonkey, joemcgill, flixos90, nacin, atimmer, duck_, boonebgorges.
Fixes #22192.


git-svn-id: https://develop.svn.wordpress.org/trunk@56681 602fd350-edb4-49c9-b593-d223f7449a82
This commit is contained in:
Felix Arntz
2023-09-25 16:23:52 +00:00
parent bccc6fcebd
commit 8d8b843eaf
3 changed files with 174 additions and 7 deletions

View File

@@ -776,21 +776,23 @@ function update_option( $option, $value, $autoload = null ) {
*/
$value = apply_filters( 'pre_update_option', $value, $option, $old_value );
/** This filter is documented in wp-includes/option.php */
$default_value = apply_filters( "default_option_{$option}", false, $option, false );
/*
* If the new and old values are the same, no need to update.
*
* Unserialized values will be adequate in most cases. If the unserialized
* data differs, the (maybe) serialized data is checked to avoid
* unnecessary database calls for otherwise identical object instances.
* An exception applies when no value is set in the database, i.e. the old value is the default.
* In that case, the new value should always be added as it may be intentional to store it rather than relying on the default.
*
* See https://core.trac.wordpress.org/ticket/38903
* See https://core.trac.wordpress.org/ticket/38903 and https://core.trac.wordpress.org/ticket/22192.
*/
if ( $value === $old_value || maybe_serialize( $value ) === maybe_serialize( $old_value ) ) {
if ( $old_value !== $default_value && _is_equal_database_value( $old_value, $value ) ) {
return false;
}
/** This filter is documented in wp-includes/option.php */
if ( apply_filters( "default_option_{$option}", false, $option, false ) === $old_value ) {
if ( $old_value === $default_value ) {
// Default setting for new options is 'yes'.
if ( null === $autoload ) {
$autoload = 'yes';
@@ -2887,3 +2889,40 @@ function filter_default_option( $default_value, $option, $passed_default ) {
return $registered[ $option ]['default'];
}
/**
* Determines whether two values will be equal when stored in the database.
*
* @since 6.4.0
* @access private
*
* @param mixed $old_value The old value to compare.
* @param mixed $new_value The new value to compare.
* @return bool True if the values are equal, false otherwise.
*/
function _is_equal_database_value( $old_value, $new_value ) {
$values = array(
'old' => $old_value,
'new' => $new_value,
);
foreach ( $values as $_key => &$_value ) {
// Cast scalars or null to a string so type discrepancies don't result in cache misses.
if ( null === $_value || is_scalar( $_value ) ) {
$_value = (string) $_value;
}
}
if ( $values['old'] === $values['new'] ) {
return true;
}
/*
* Unserialized values will be adequate in most cases. If the unserialized
* data differs, the (maybe) serialized data is checked to avoid
* unnecessary database calls for otherwise identical object instances.
*
* See https://core.trac.wordpress.org/ticket/38903
*/
return maybe_serialize( $old_value ) === maybe_serialize( $new_value );
}

View File

@@ -0,0 +1,71 @@
<?php
/**
* Tests for _is_equal_database_value().
*
* @group option
*
* @covers ::_is_equal_database_value
*/
class Tests_Option_IsEqualDatabaseValue extends WP_UnitTestCase {
/**
* @ticket 22192
*
* @dataProvider data_is_equal_database_value
*
* @param mixed $old_value The old value to compare.
* @param mixed $new_value The new value to compare.
* @param bool $expected The expected result.
*/
public function test_is_equal_database_value( $old_value, $new_value, $expected ) {
$this->assertSame( $expected, _is_equal_database_value( $old_value, $new_value ) );
}
/**
* Data provider.
*
* @return array
*/
public function data_is_equal_database_value() {
return array(
// Equal values.
array( '123', '123', true ),
// Not equal values.
array( '123', '456', false ),
// Truthy.
array( 1, '1', true ),
array( 1.0, '1', true ),
array( '1', '1', true ),
array( true, '1', true ),
array( '1.0', '1', false ),
array( ' ', '1', false ),
array( array( '0' ), '1', false ),
array( new stdClass(), '1', false ),
array( 'Howdy, admin!', '1', false ),
// False-ish values and empty strings.
array( 0, '0', true ),
array( 0.0, '0', true ),
array( '0', '0', true ),
array( '', '0', false ),
array( false, '0', false ),
array( null, '0', false ),
array( array(), '0', false ),
// Object values.
array( (object) array( 'foo' => 'bar' ), (object) array( 'foo' => 'bar' ), true ),
array( (object) array( 'foo' => 'bar' ), (object) array( 'foo' => 'baz' ), false ),
array( (object) array( 'foo' => 'bar' ), serialize( (object) array( 'foo' => 'bar' ) ), false ),
array( serialize( (object) array( 'foo' => 'bar' ) ), (object) array( 'foo' => 'bar' ), false ),
array( serialize( (object) array( 'foo' => 'bar' ) ), (object) array( 'foo' => 'baz' ), false ),
// Serialized values.
array( array( 'foo' => 'bar' ), serialize( array( 'foo' => 'bar' ) ), false ),
array( array( 'foo' => 'bar' ), serialize( array( 'foo' => 'baz' ) ), false ),
array( serialize( (object) array( 'foo' => 'bar' ) ), serialize( (object) array( 'foo' => 'bar' ) ), true ),
array( serialize( (object) array( 'foo' => 'bar' ) ), serialize( (object) array( 'foo' => 'baz' ) ), false ),
);
}
}

View File

@@ -524,4 +524,61 @@ class Tests_Option_Option extends WP_UnitTestCase {
array( null, array(), true ),
);
}
/**
* Tests that update_option() stores an option that uses
* an unfiltered default value of (bool) false.
*
* @ticket 22192
*
* @covers ::update_option
*/
public function test_update_option_should_store_option_with_default_value_false() {
global $wpdb;
$option = 'update_option_default_false';
update_option( $option, false );
$actual = $wpdb->query(
$wpdb->prepare(
"SELECT option_name FROM $wpdb->options WHERE option_name = %s LIMIT 1",
$option
)
);
$this->assertSame( 1, $actual );
}
/**
* Tests that update_option() stores an option that uses
* a filtered default value.
*
* @ticket 22192
*
* @covers ::update_option
*/
public function test_update_option_should_store_option_with_filtered_default_value() {
global $wpdb;
$option = 'update_option_custom_default';
$default_value = 'default-value';
add_filter(
"default_option_{$option}",
static function () use ( $default_value ) {
return $default_value;
}
);
update_option( $option, $default_value );
$actual = $wpdb->query(
$wpdb->prepare(
"SELECT option_name FROM $wpdb->options WHERE option_name = %s LIMIT 1",
$option
)
);
$this->assertSame( 1, $actual );
}
}