Eliminate switch_to_blog() from get_blog_option(), update_blog_option(), WP_Site::get_details(), and get_blog_post() using $wpdb->get_blog_prefix()

----

== Description ==

`switch_to_blog()` mutates six globals and, on the fallback object-cache implementation, **wipes the entire object cache** via `wp_cache_init()` inside `wp_cache_switch_to_blog_fallback()`. Every `switch_to_blog()` / `restore_current_blog()` pair is two full global-state mutations.

Several core functions use `switch_to_blog()` internally even though they only need to read or write a single row in a per-site table. This ticket proposes replacing those internal switches with direct queries using `$wpdb->get_blog_prefix( $blog_id )`, which is a **pure function** — no side-effects, no globals written.

=== Globals mutated by switch_to_blog() ===

||= Global mutated =||= What changes =||
|| `$wpdb->blogid` / `$wpdb->prefix` || Table prefix rewritten (e.g. `wp_` → `wp_3_`) ||
|| `$wpdb->options`, `$wpdb->posts`, … || All per-blog table properties rewritten ||
|| `$GLOBALS['table_prefix']` || Mirrors the new prefix ||
|| `$GLOBALS['blog_id']` || Set to new blog ID ||
|| `$GLOBALS['_wp_switched_stack']` || Previous ID pushed ||
|| `$GLOBALS['switched']` || Set to `true` ||
|| `$wp_object_cache` || Full cache wipe on fallback ||

=== Real-world impact ===

Any plugin or core code that iterates over sites and accesses `blogname`, `siteurl`, or other per-site options triggers `switch_to_blog()` for each site — either directly via `get_blog_option()` or indirectly via `WP_Site::get_details()` (called by `WP_Site::__get()` for magic properties like `blogname` and `siteurl`).

For a network with 100 sites, a single loop fetching `blogname` and `siteurl` causes **~200 global-state mutations** (100 switch + 100 restore).

----

== The Key Insight ==

`wpdb::get_blog_prefix( $blog_id )` already exists as a public method, accepts an explicit blog ID, and returns the correct table prefix **without touching any global state**:

{{{#!php
// From class-wpdb.php — pure computation, no side-effects:
public function get_blog_prefix( $blog_id = null ) {
    if ( is_multisite() ) {
        if ( null === $blog_id ) {
            $blog_id = $this->blogid;
        }
        $blog_id = (int) $blog_id;
        if ( defined( 'MULTISITE' ) && ( 0 === $blog_id || 1 === $blog_id ) ) {
            return $this->base_prefix;
        } else {
            return $this->base_prefix . $blog_id . '_';
        }
    }
    return $this->base_prefix;
}
}}}

Core already uses this pattern for non-options tables (e.g. `ms-functions.php` line 2003 queries `{prefix}posts` directly). The only reason `{prefix}options` is never queried this way is that `get_option()` has no table parameter — not because direct queries are prohibited.

----

== Proposed Changes ==

=== 1. New private helper: `_get_option_from_blog()` ===

Add to `wp-includes/ms-blogs.php`. Reads a single option from any site's `wp_N_options` table without switching. Handles object-cache correctly (same `alloptions` / `notoptions` groups as `get_option()`).

{{{#!php
/**
 * Retrieves an option value for a specific site without switching blog context.
 *
 * Uses the object cache (same 'options'/'notoptions' groups as get_option()),
 * falling back to a direct DB query with the site's table prefix.
 *
 * @since 7.x.0
 * @access private
 *
 * @param int    $blog_id  Site ID.
 * @param string $option   Option name.
 * @param mixed  $default  Default value if option not found.
 * @return mixed Option value or $default.
 */
function _get_option_from_blog( int $blog_id, string $option, mixed $default = false ): mixed {
    global $wpdb;

    $blog_id = (int) $blog_id;

    // Fast path: current blog — delegate to get_option().
    if ( get_current_blog_id() === $blog_id ) {
        return get_option( $option, $default );
    }

    // Check object cache.
    $alloptions_cache = wp_cache_get( $blog_id, 'blog-alloptions' );
    if ( is_array( $alloptions_cache ) && array_key_exists( $option, $alloptions_cache ) ) {
        return $alloptions_cache[ $option ] ?? $default;
    }

    $notoptions = wp_cache_get( $blog_id, 'blog-notoptions' );
    if ( is_array( $notoptions ) && isset( $notoptions[ $option ] ) ) {
        return $default;
    }

    // Direct DB query using get_blog_prefix() — no switch.
    $table = $wpdb->get_blog_prefix( $blog_id ) . 'options';
    $row   = $wpdb->get_row(
        $wpdb->prepare(
            "SELECT option_value FROM `{$table}` WHERE option_name = %s LIMIT 1",
            $option
        )
    );

    if ( null === $row ) {
        $notoptions           = is_array( $notoptions ) ? $notoptions : [];
        $notoptions[ $option ] = true;
        wp_cache_set( $blog_id, $notoptions, 'blog-notoptions' );
        return $default;
    }

    $value = maybe_unserialize( $row->option_value );

    return apply_filters( "blog_option_{$option}", $value, $blog_id );
}
}}}

=== 2. Refactor `get_blog_option()` ===

Replace the `switch_to_blog()` / `get_option()` / `restore_current_blog()` sequence with a single call to the new helper.

{{{#!php
// BEFORE (ms-blogs.php):
function get_blog_option( $id, $option, $default_value = false ) {
    $id = (int) $id;
    if ( empty( $id ) ) { $id = get_current_blog_id(); }

    if ( get_current_blog_id() === $id ) {
        return get_option( $option, $default_value );
    }

    switch_to_blog( $id );
    $value = get_option( $option, $default_value );
    restore_current_blog();

    return apply_filters( "blog_option_{$option}", $value, $id );
}

// AFTER:
function get_blog_option( $id, $option, $default_value = false ) {
    $id = (int) $id;
    if ( empty( $id ) ) { $id = get_current_blog_id(); }

    return _get_option_from_blog( $id, $option, $default_value );
}
}}}

=== 3. Refactor `update_blog_option()` ===

Replace `switch_to_blog()` + `update_option()` + `restore_current_blog()` with a direct `$wpdb->update/insert` against `{prefix}options`.

{{{#!php
// AFTER:
function update_blog_option( $id, $option, $value, $deprecated = null ) {
    global $wpdb;
    $id = (int) $id;

    if ( null !== $deprecated ) {
        _deprecated_argument( __FUNCTION__, '3.1.0' );
    }

    if ( get_current_blog_id() === $id ) {
        return update_option( $option, $value );
    }

    $table      = $wpdb->get_blog_prefix( $id ) . 'options';
    $serialized = maybe_serialize( $value );
    $exists     = $wpdb->get_var(
        $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE option_name = %s", $option )
    );

    if ( $exists ) {
        $result = $wpdb->update(
            $table,
            [ 'option_value' => $serialized ],
            [ 'option_name'  => $option ],
            [ '%s' ],
            [ '%s' ]
        );
    } else {
        $result = $wpdb->insert(
            $table,
            [ 'option_name' => $option, 'option_value' => $serialized, 'autoload' => 'yes' ],
            [ '%s', '%s', '%s' ]
        );
    }

    // Bust per-blog option caches.
    wp_cache_delete( $id, 'blog-alloptions' );
    wp_cache_delete( $id, 'blog-notoptions' );

    return false !== $result;
}
}}}

Apply the same pattern to `add_blog_option()` and `delete_blog_option()`.

=== 4. Refactor `WP_Site::get_details()` ===

In `class-wp-site.php`, `get_details()` (private) fetches `blogname`, `siteurl`, `post_count`, and `home` via `switch_to_blog()`. Replace with four calls to `_get_option_from_blog()`:

{{{#!php
// AFTER:
private function get_details() {
    $details = wp_cache_get( $this->blog_id, 'site-details' );

    if ( false === $details ) {
        $id      = (int) $this->blog_id;
        $details = new stdClass();
        foreach ( get_object_vars( $this ) as $key => $value ) {
            $details->$key = $value;
        }
        $details->blogname   = _get_option_from_blog( $id, 'blogname' );
        $details->siteurl    = _get_option_from_blog( $id, 'siteurl' );
        $details->post_count = _get_option_from_blog( $id, 'post_count', 0 );
        $details->home       = _get_option_from_blog( $id, 'home' );

        wp_cache_set( $this->blog_id, $details, 'site-details' );
    }

    $details = apply_filters_deprecated( 'blog_details', [ $details ], '4.7.0', 'site_details' );
    $details = apply_filters( 'site_details', $details );

    return $details;
}
}}}

=== 5. Refactor `get_blog_post()` ===

In `ms-functions.php`, replace `switch_to_blog()` + `get_post()` + `restore_current_blog()` with a direct query against `{prefix}posts`:

{{{#!php
// AFTER:
function get_blog_post( $blog_id, $post_id ) {
    global $wpdb;

    $blog_id = (int) $blog_id;
    $post_id = (int) $post_id;

    if ( get_current_blog_id() === $blog_id ) {
        return get_post( $post_id );
    }

    $table = $wpdb->get_blog_prefix( $blog_id ) . 'posts';
    $post  = $wpdb->get_row(
        $wpdb->prepare( "SELECT * FROM `{$table}` WHERE ID = %d LIMIT 1", $post_id )
    );

    if ( ! $post ) {
        return null;
    }

    return sanitize_post( new WP_Post( $post ), 'raw' );
}
}}}

----

== Object-Cache Considerations ==

The new `_get_option_from_blog()` helper must:

 * Use the same cache group strategy as `get_option()` — `blog-alloptions` for autoloaded options, `blog-notoptions` for known-missing options.
 * Bust caches on write — `add_blog_option`, `update_blog_option`, `delete_blog_option` must invalidate the same keys.
 * Lazy-load `alloptions` — on first access for a given `$blog_id`, fetch all autoloaded options in one query and cache them in `blog-alloptions`, mirroring `wp_load_alloptions()`.

----

== Out of Scope ==

These functions have deeper coupling to the switched context and are out of scope for an initial patch:

||= Function =||= Reason =||
|| `wp_initialize_site()` || Runs dozens of core operations on a new site's tables ||
|| `wp_uninitialize_site()` || Drops tables, clears all site data ||
|| Third-party plugin code || Cannot be fixed in core ||

----

== Files Changed ==

||= File =||= Change =||
|| `wp-includes/ms-blogs.php` || Add `_get_option_from_blog()` private helper ||
|| `wp-includes/ms-blogs.php` || Refactor `get_blog_option()` to use helper ||
|| `wp-includes/ms-blogs.php` || Refactor `update_blog_option()` — direct `$wpdb` query ||
|| `wp-includes/ms-blogs.php` || Refactor `add_blog_option()` — direct `$wpdb->insert` ||
|| `wp-includes/ms-blogs.php` || Refactor `delete_blog_option()` — direct `$wpdb->delete` ||
|| `wp-includes/class-wp-site.php` || Refactor `WP_Site::get_details()` to use `_get_option_from_blog()` ||
|| `wp-includes/ms-functions.php` || Refactor `get_blog_post()` — direct `$wpdb` query ||

All changes exploit `$wpdb->get_blog_prefix( $blog_id )`, which is already public, purpose-built, and side-effect-free.

----

== Testing Notes ==

 * Unit tests for `get_blog_option()`, `update_blog_option()`, `add_blog_option()`, `delete_blog_option()` — verify return values match current behaviour.
 * Unit test for `WP_Site::__get('blogname')` / `WP_Site::__get('siteurl')` — verify values match after refactor.
 * Verify object cache is populated and busted correctly (test with both fallback and persistent cache drop-ins).
 * Verify `$GLOBALS['switched']` is **not** set to `true` after `get_blog_option()` (regression test for the switch removal).
 * Performance benchmark: loop over N sites calling `get_blog_option( $id, 'blogname' )` — measure wall time before/after.

----

Component: Multisite[[BR]]
Focuses: performance[[BR]]
Type: enhancement[[BR]]
Version: trunk[[BR]]
Keywords: has-patch needs-testing
