7,8c7,14
< 		// This is not meant to be foolproof -- but it will catch obviously incorrect usage.
< 		if ( strpos( $query, '%' ) === false ) {
---
> 		/*
> 		 * This is not meant to be foolproof -- but it will catch obviously incorrect usage.
> 		 *
> 		 * Note: str_contains() is not used here, as this file can be included
> 		 * directly outside of WordPress core, e.g. by HyperDB, in which case
> 		 * the polyfills from wp-includes/compat.php are not loaded.
> 		 */
> 		if ( false === strpos( $query, '%' ) ) {
43c49
< 		$query = preg_replace( "/%(?:%|$|(?!($allowed_format)?[sdfFi]))/", '%%\\1', $query );
---
> 		$query = preg_replace( "/%(?:%|$|(?!(\.\.\.)?($allowed_format)?[sdfFi]))/", '%%\\1', $query );
46c52
< 		$split_query = preg_split( "/(^|[^%]|(?:%%)+)(%(?:$allowed_format)?[sdfFi])/", $query, -1, PREG_SPLIT_DELIM_CAPTURE );
---
> 		$split_query = preg_split( "/(^|[^%]|(?:%%)+)(%(?:\.\.\.)?(?:$allowed_format)?[sdfFi])/", $query, -1, PREG_SPLIT_DELIM_CAPTURE );
57a64,66
> 		if ( $passed_as_array && isset( $split_query[2] ) && substr( $split_query[2], 1, 3 ) === '...' && isset( $args[0][0] ) && false === is_array( $args[0][0] ) ) {
> 			$passed_as_array = false; // The first (and only) placeholder is using variadics (e.g. '%...d'), and that array has *not* been $passed_as_array, e.g. `$wpdb->prepare('id IN (%...d)', [ 1, 2, 3 ] );`.
> 		}
64c73,74
< 		$arg_id          = 0;
---
> 		$arg_current     = 0;
> 		$arg_offset      = 0;
66a77
> 		$arg_variadics   = array();
68a80,82
> 
> 			// Glue (-2), any leading characters (-1); then the placeholder.
> 			$prefix      = $split_query[ $key - 2 ] . $split_query[ $key - 1 ];
70a85,89
> 			$variadic = ( '...' === substr( $placeholder, 1, 3 ) );
> 			if ( $variadic ) {
> 				$placeholder = '%' . substr( $placeholder, 4 );
> 			}
> 
74,76c93,100
< 			if ( 'f' === $type && true === $this->allow_unsafe_unquoted_parameters
< 				&& '%' === substr( $split_query[ $key - 1 ], -1, 1 )
< 			) {
---
> 			$escaped = null;
> 			for ( $l = ( strlen( $prefix ) - 1 ); $l >= 0; $l-- ) {
> 				if ( '%' === $prefix[ $l ] ) {
> 					$escaped = ( null === $escaped ? true : ! $escaped );
> 				} else {
> 					break;
> 				}
> 			}
77a102
> 			if ( 'f' === $type && true === $this->allow_unsafe_unquoted_parameters && null !== $escaped ) {
84c109
< 				 * an extra "%", to give the fully escaped "%%%%f" (not a placeholder).
---
> 				 * an extra "%", to give the fully escaped "%%%%f" (so it's never a placeholder).
87,92c112
< 				$s = $split_query[ $key - 2 ] . $split_query[ $key - 1 ];
< 				$k = 1;
< 				$l = strlen( $s );
< 				while ( $k <= $l && '%' === $s[ $l - $k ] ) {
< 					$k++;
< 				}
---
> 				$new_placeholder = '%' . ( true === $escaped ? '' : '%' ) . $format . $type;
94c114,122
< 				$placeholder = '%' . ( $k % 2 ? '%' : '' ) . $format . $type;
---
> 				--$placeholder_count;
> 
> 			} elseif ( true === $escaped ) { // Don't change the $placeholder to contain an argnum.
> 
> 				if ( $variadic ) {
> 					$new_placeholder = '%...' . substr( $placeholder, 1 );
> 				} else {
> 					$new_placeholder = $placeholder;
> 				}
102,103c130
< 					$type        = 'F';
< 					$placeholder = '%' . $format . $type;
---
> 					$type = 'F';
106,122c133,141
< 				if ( 'i' === $type ) {
< 					$placeholder = '`%' . $format . 's`';
< 					// Using a simple strpos() due to previous checking (e.g. $allowed_format).
< 					$argnum_pos = strpos( $format, '$' );
< 
< 					if ( false !== $argnum_pos ) {
< 						// sprintf() argnum starts at 1, $arg_id from 0.
< 						$arg_identifiers[] = ( ( (int) substr( $format, 0, $argnum_pos ) ) - 1 );
< 					} else {
< 						$arg_identifiers[] = $arg_id;
< 					}
< 				} elseif ( 'd' !== $type && 'F' !== $type ) {
< 					/*
< 					 * i.e. ( 's' === $type ), where 'd' and 'F' keeps $placeholder unchanged,
< 					 * and we ensure string escaping is used as a safe default (e.g. even if 'x').
< 					 */
< 					$argnum_pos = strpos( $format, '$' );
---
> 				$set_format = ( '' !== $format );
> 
> 				$argnum_pos = strpos( $format, '$' );
> 				if ( false !== $argnum_pos ) {
> 					$argnum_value = (int) substr( $format, 0, $argnum_pos );
> 					$format       = substr( $format, ( $argnum_pos + 1 ) );
> 				} else {
> 					$argnum_value = ++$arg_current; // Argnum starts at 1.
> 				}
124,125c143,152
< 					if ( false !== $argnum_pos ) {
< 						$arg_strings[] = ( ( (int) substr( $format, 0, $argnum_pos ) ) - 1 );
---
> 				$new_argnum = ( $argnum_value + $arg_offset );
> 
> 				if ( $variadic ) {
> 					if ( 'i' === $type ) {
> 						$new_prefix        = '`%';
> 						$new_suffix        = $format . 's`';
> 						$arg_identifiers[] = ( $argnum_value - 1 );
> 					} elseif ( 'd' === $type || 'F' === $type ) {
> 						$new_prefix = '%'; // No need to quote integers or floats.
> 						$new_suffix = $format . $type;
127c154,155
< 						$arg_strings[] = $arg_id;
---
> 						$new_prefix = "'%";
> 						$new_suffix = $format . $type . "'";
129,138c157,177
< 
< 					/*
< 					 * Unquoted strings for backward compatibility (dangerous).
< 					 * First, "numbered or formatted string placeholders (eg, %1$s, %5s)".
< 					 * Second, if "%s" has a "%" before it, even if it's unrelated (e.g. "LIKE '%%%s%%'").
< 					 */
< 					if ( true !== $this->allow_unsafe_unquoted_parameters
< 						|| ( '' === $format && '%' !== substr( $split_query[ $key - 1 ], -1, 1 ) )
< 					) {
< 						$placeholder = "'%" . $format . "s'";
---
> 					$new_placeholder = '';
> 					$arg_count       = count( $args[ ( $argnum_value - 1 ) ] ); // The argnum in $format starts from 1, but $args index start from 0.
> 					for ( $k = 0; $k < $arg_count; $k++ ) {
> 						$new_placeholder .= $new_prefix . ( $new_argnum + $k ) . '$' . $new_suffix . ',';
> 					}
> 					$new_placeholder = substr( $new_placeholder, 0, -1 );
> 					$arg_offset     += ( $arg_count - 1 ); // The arg already counts as 1, the offset is how many extra to move.
> 					$arg_variadics[] = ( $argnum_value - 1 );
> 				} elseif ( 'i' === $type ) {
> 					$new_placeholder   = '`%' . $new_argnum . '$' . $format . 's`';
> 					$arg_identifiers[] = ( $argnum_value - 1 );
> 				} elseif ( 'd' === $type || 'F' === $type ) {
> 					$new_placeholder = '%' . $new_argnum . '$' . $format . $type; // No need to quote integers or floats.
> 				} else { // i.e. ( 's' === $type ).
> 					if ( true === $this->allow_unsafe_unquoted_parameters && ( $set_format || null !== $escaped ) ) {
> 							// Unquoted strings for backward compatibility (dangerous).
> 							// First, "numbered or formatted string placeholders (eg, %1$s, %5s)"
> 							// Second, if "%s" has a "%" before it, even if it's unrelated (e.g. "LIKE '%%%s%%'").
> 						$new_placeholder = '%' . $new_argnum . '$' . $format . 's';
> 					} else {
> 						$new_placeholder = "'%" . $new_argnum . '$' . $format . "s'";
139a179
> 					$arg_strings[] = ( $argnum_value - 1 );
143,144c183,184
< 			// Glue (-2), any leading characters (-1), then the new $placeholder.
< 			$new_query .= $split_query[ $key - 2 ] . $split_query[ $key - 1 ] . $placeholder;
---
> 			// Glue (-2), any leading characters (-1), then $new_placeholder.
> 			$new_query .= $prefix . $new_placeholder;
147c187
< 			$arg_id++;
---
> 
260c300,306
< 			if ( in_array( $i, $arg_identifiers, true ) ) {
---
> 			if ( in_array( $i, $arg_variadics, true ) ) {
> 				if ( in_array( $i, $arg_identifiers, true ) ) {
> 					$args_escaped = array_merge( $args_escaped, array_map( array( $this, '_escape_identifier_value' ), $value ) );
> 				} else {
> 					$args_escaped = array_merge( $args_escaped, array_map( array( $this, '_real_escape' ), $value ) );
> 				}
> 			} elseif ( in_array( $i, $arg_identifiers, true ) ) {