| @@ -49,9 +49,15 @@ | ||
| 49 | 49 | */ |
| 50 | 50 | public static function route_for( string $url ): string { |
| 51 | 51 | $path = (string) wp_parse_url( $url, PHP_URL_PATH ); |
| 52 | 52 | |
| 53 | - return '' === $path ? '/' : $path; | |
| 53 | + if ( '' === $path ) { | |
| 54 | + return '/'; | |
| 55 | + } | |
| 56 | + | |
| 57 | + // Guarantee a canonical leading slash — malformed input (e.g. a bare | |
| 58 | + // scheme string) must never mint a route outside the path namespace. | |
| 59 | + return '/' === $path[0] ? $path : '/' . $path; | |
| 54 | 60 | } |
| 55 | 61 | |
| 56 | 62 | /** |
| 57 | 63 | * Canonical route key for the current request. |
| @@ -75,9 +81,9 @@ | ||
| 75 | 81 | if ( empty( $records ) ) { |
| 76 | 82 | return; |
| 77 | 83 | } |
| 78 | 84 | |
| 79 | - $now = current_time( 'mysql' ); | |
| 85 | + $now = gmdate( 'Y-m-d H:i:s' ); // UTC — compared against UTC_TIMESTAMP() in gc(). | |
| 80 | 86 | $table = $this->usage_table(); |
| 81 | 87 | |
| 82 | 88 | // Collapse to one row per (page, family, weight, style); merge any |
| 83 | 89 | // duplicates within this batch (strongest rendered/above_fold wins, |
| @@ -129,9 +135,13 @@ | ||
| 129 | 135 | $placeholders = array(); |
| 130 | 136 | $args = array(); |
| 131 | 137 | |
| 132 | 138 | foreach ( $by_key as $row ) { |
| 133 | - $placeholders[] = '(%s,%s,%d,%s,%s,%s,%s,%d,%d,1,%s)'; | |
| 139 | + // A beacon measurement that saw the variant loaded but not rendered | |
| 140 | + // counts one "miss" toward the scope-out corroboration threshold. | |
| 141 | + $miss = ( 'beacon' === $row['origin'] && 0 === $row['rendered'] ) ? 1 : 0; | |
| 142 | + | |
| 143 | + $placeholders[] = '(%s,%s,%d,%s,%s,%s,%s,%d,%d,1,%d,%s)'; | |
| 134 | 144 | array_push( |
| 135 | 145 | $args, |
| 136 | 146 | $row['key'], |
| 137 | 147 | $page_url, |
| @@ -141,21 +151,29 @@ | ||
| 141 | 151 | $row['style'], |
| 142 | 152 | $row['origin'], |
| 143 | 153 | $row['rendered'], |
| 144 | 154 | $row['above_fold'], |
| 155 | + $miss, | |
| 145 | 156 | $now |
| 146 | 157 | ); |
| 147 | 158 | } |
| 148 | 159 | |
| 160 | + $alias = $this->dup_alias(); | |
| 161 | + | |
| 149 | 162 | $sql = "INSERT INTO {$table} |
| 150 | - (usage_key, page_url, page_id, family, weight, style, origin, rendered, above_fold, hits, last_seen) | |
| 151 | - VALUES " . implode( ',', $placeholders ) . " | |
| 163 | + (usage_key, page_url, page_id, family, weight, style, origin, rendered, above_fold, hits, beacon_misses, last_seen) | |
| 164 | + VALUES " . implode( ',', $placeholders ) . "{$alias} | |
| 152 | 165 | ON DUPLICATE KEY UPDATE |
| 153 | 166 | hits = hits + 1, |
| 154 | - rendered = GREATEST(rendered, VALUES(rendered)), | |
| 155 | - above_fold = GREATEST(above_fold, VALUES(above_fold)), | |
| 156 | - origin = VALUES(origin), | |
| 157 | - last_seen = VALUES(last_seen)"; | |
| 167 | + rendered = GREATEST(rendered, {$this->dup( 'rendered' )}), | |
| 168 | + above_fold = GREATEST(above_fold, {$this->dup( 'above_fold' )}), | |
| 169 | + origin = {$this->dup( 'origin' )}, | |
| 170 | + beacon_misses = CASE | |
| 171 | + WHEN {$this->dup( 'origin' )} = 'beacon' AND {$this->dup( 'rendered' )} = 1 THEN 0 | |
| 172 | + WHEN {$this->dup( 'origin' )} = 'beacon' AND {$this->dup( 'rendered' )} = 0 THEN beacon_misses + 1 | |
| 173 | + ELSE beacon_misses | |
| 174 | + END, | |
| 175 | + last_seen = {$this->dup( 'last_seen' )}"; | |
| 158 | 176 | |
| 159 | 177 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared |
| 160 | 178 | $wpdb->query( $wpdb->prepare( $sql, $args ) ); |
| 161 | 179 | } |
| @@ -198,15 +216,20 @@ | ||
| 198 | 216 | * are family-level and based on the page's authorial font-family, so they're |
| 199 | 217 | * robust to weight/style differences and self-correcting. |
| 200 | 218 | * |
| 201 | 219 | * - `rendered` drives page scoping (which fonts load on this page). |
| 202 | - * - `above_fold` drives preload (which of them to fetch early). | |
| 220 | + * - `above_fold` the families above the fold. | |
| 221 | + * - `fold_faces` drives preload: for every family rendered here, the faces | |
| 222 | + * ('weight|style') the beacon saw above the fold — empty when the family | |
| 223 | + * renders only further down. (2.0.4) Preload used to take the whole | |
| 224 | + * family, so a family with many weights filled every slot with weights | |
| 225 | + * the page never shows. | |
| 203 | 226 | * |
| 204 | 227 | * Empty `rendered` means the route hasn't been measured yet — callers must |
| 205 | 228 | * neither scope nor preload from no data. |
| 206 | 229 | * |
| 207 | 230 | * @param string $route Canonical route (path). |
| 208 | - * @return array{rendered:array<string,bool>,above_fold:array<string,bool>,scope_out:array<string,bool>,measured:bool} | |
| 231 | + * @return array{rendered:array<string,bool>,above_fold:array<string,bool>,fold_faces:array<string,array<string,bool>>,scope_out:array<string,bool>,measured:bool} | |
| 209 | 232 | */ |
| 210 | 233 | public function route_render_profile( string $route ): array { |
| 211 | 234 | global $wpdb; |
| 212 | 235 | |
| @@ -212,8 +235,9 @@ | ||
| 212 | 235 | |
| 213 | 236 | $out = array( |
| 214 | 237 | 'rendered' => array(), |
| 215 | 238 | 'above_fold' => array(), |
| 239 | + 'fold_faces' => array(), | |
| 216 | 240 | 'scope_out' => array(), |
| 217 | 241 | 'measured' => false, |
| 218 | 242 | ); |
| 219 | 243 | |
| @@ -228,43 +252,72 @@ | ||
| 228 | 252 | // confirmed scope-out ONLY when a beacon has actually measured it and |
| 229 | 253 | // never saw it render. A family that hasn't been beacon-measured yet |
| 230 | 254 | // (e.g. a font just added/replaced in the Customizer) is NEVER scoped |
| 231 | 255 | // out — it loads normally until a beacon has had a chance to judge it. |
| 256 | + // Grouped per face (still one query); folded per family below. | |
| 232 | 257 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery |
| 233 | 258 | $rows = $wpdb->get_results( |
| 234 | 259 | $wpdb->prepare( |
| 235 | - "SELECT family, | |
| 260 | + "SELECT family, weight, style, | |
| 236 | 261 | MAX(rendered) AS rnd, |
| 237 | 262 | MAX(CASE WHEN rendered = 1 THEN above_fold ELSE 0 END) AS af, |
| 238 | - MAX(CASE WHEN origin = 'beacon' THEN 1 ELSE 0 END) AS beaconed | |
| 263 | + MAX(CASE WHEN origin = 'beacon' THEN 1 ELSE 0 END) AS beaconed, | |
| 264 | + MAX(beacon_misses) AS misses | |
| 239 | 265 | FROM {$this->usage_table()} |
| 240 | 266 | WHERE page_url = %s |
| 241 | - GROUP BY family", | |
| 267 | + GROUP BY family, weight, style", | |
| 242 | 268 | $route |
| 243 | 269 | ), |
| 244 | 270 | ARRAY_A |
| 245 | 271 | ); |
| 246 | 272 | |
| 247 | - if ( $rows ) { | |
| 248 | - foreach ( $rows as $r ) { | |
| 249 | - $fam = strtolower( (string) $r['family'] ); | |
| 250 | - $rendered = ! empty( $r['rnd'] ); | |
| 251 | - $beaconed = ! empty( $r['beaconed'] ); | |
| 273 | + $families = array(); | |
| 252 | 274 | |
| 253 | - if ( $beaconed ) { | |
| 254 | - $out['measured'] = true; | |
| 255 | - } | |
| 275 | + foreach ( (array) $rows as $r ) { | |
| 276 | + $fam = strtolower( (string) $r['family'] ); | |
| 277 | + $agg = $families[ $fam ] ?? array( 'rnd' => 0, 'af' => 0, 'beaconed' => 0, 'misses' => 0, 'faces' => array() ); | |
| 256 | 278 | |
| 257 | - if ( $rendered ) { | |
| 258 | - $out['rendered'][ $fam ] = true; | |
| 279 | + $agg['rnd'] = max( $agg['rnd'], (int) $r['rnd'] ); | |
| 280 | + $agg['af'] = max( $agg['af'], (int) $r['af'] ); | |
| 281 | + $agg['beaconed'] = max( $agg['beaconed'], (int) $r['beaconed'] ); | |
| 282 | + $agg['misses'] = max( $agg['misses'], (int) $r['misses'] ); | |
| 259 | 283 | |
| 260 | - if ( ! empty( $r['af'] ) ) { | |
| 261 | - $out['above_fold'][ $fam ] = true; | |
| 262 | - } | |
| 263 | - } elseif ( $beaconed ) { | |
| 264 | - // Beacon measured it and never saw it render → scope out. | |
| 265 | - $out['scope_out'][ $fam ] = true; | |
| 284 | + if ( ! empty( $r['af'] ) ) { | |
| 285 | + $agg['faces'][ $r['weight'] . '|' . $r['style'] ] = true; | |
| 286 | + } | |
| 287 | + | |
| 288 | + $families[ $fam ] = $agg; | |
| 289 | + } | |
| 290 | + | |
| 291 | + /** | |
| 292 | + * Consistent beacon measurements required before a never-rendered | |
| 293 | + * family is scoped off a page. Corroboration means one bad or forged | |
| 294 | + * report can't remove a font (beacons are throttled hourly, so this is | |
| 295 | + * also a minimum observation window). | |
| 296 | + * | |
| 297 | + * @param int $min | |
| 298 | + */ | |
| 299 | + $min_misses = max( 1, (int) apply_filters( 'easyfonts_scope_out_min_measurements', 2 ) ); | |
| 300 | + | |
| 301 | + foreach ( $families as $fam => $r ) { | |
| 302 | + $rendered = ! empty( $r['rnd'] ); | |
| 303 | + $beaconed = ! empty( $r['beaconed'] ); | |
| 304 | + | |
| 305 | + if ( $beaconed ) { | |
| 306 | + $out['measured'] = true; | |
| 307 | + } | |
| 308 | + | |
| 309 | + if ( $rendered ) { | |
| 310 | + $out['rendered'][ $fam ] = true; | |
| 311 | + $out['fold_faces'][ $fam ] = $r['faces']; | |
| 312 | + | |
| 313 | + if ( ! empty( $r['af'] ) ) { | |
| 314 | + $out['above_fold'][ $fam ] = true; | |
| 266 | 315 | } |
| 316 | + } elseif ( $beaconed && (int) $r['misses'] >= $min_misses ) { | |
| 317 | + // Multiple beacons measured it and never saw it render → | |
| 318 | + // confirmed scope-out. | |
| 319 | + $out['scope_out'][ $fam ] = true; | |
| 267 | 320 | } |
| 268 | 321 | } |
| 269 | 322 | |
| 270 | 323 | return $out; |
| @@ -276,14 +329,27 @@ | ||
| 276 | 329 | * wherever they appear without consulting per-route data (and even before a |
| 277 | 330 | * given route has been measured), which is the cheap path: no per-route |
| 278 | 331 | * check needed for the fonts whose answer is the same everywhere. |
| 279 | 332 | * |
| 280 | - * @return array<string,bool> Lowercased family => true. | |
| 333 | + * (2.0.4) With the faces ('weight|style') those families render in. A value | |
| 334 | + * of `true` is a family stored by 2.0.3 without its faces — it stands for | |
| 335 | + * the whole family until the next recompute (next beacon, or weekly GC). | |
| 336 | + * | |
| 337 | + * @return array<string,array<string,bool>|true> Lowercased family => faces. | |
| 281 | 338 | */ |
| 282 | - public function global_preload_families(): array { | |
| 339 | + public function global_preload_faces(): array { | |
| 283 | 340 | $list = get_option( 'easyfonts_global_preload', array() ); |
| 341 | + $out = array(); | |
| 284 | 342 | |
| 285 | - return is_array( $list ) ? array_fill_keys( array_map( 'strval', $list ), true ) : array(); | |
| 343 | + foreach ( is_array( $list ) ? $list : array() as $key => $value ) { | |
| 344 | + if ( is_array( $value ) ) { | |
| 345 | + $out[ (string) $key ] = array_fill_keys( array_map( 'strval', $value ), true ); | |
| 346 | + } elseif ( is_int( $key ) ) { | |
| 347 | + $out[ strtolower( (string) $value ) ] = true; | |
| 348 | + } | |
| 349 | + } | |
| 350 | + | |
| 351 | + return $out; | |
| 286 | 352 | } |
| 287 | 353 | |
| 288 | 354 | /** |
| 289 | 355 | * Recompute and cache the uniformly-above-the-fold family set. A family |
| @@ -288,10 +354,11 @@ | ||
| 288 | 354 | /** |
| 289 | 355 | * Recompute and cache the uniformly-above-the-fold family set. A family |
| 290 | 356 | * qualifies when every measured row for it (across all routes/variants where |
| 291 | 357 | * it renders) is above the fold — so its preload answer doesn't vary by page |
| 292 | - * and we can skip per-route checks for it. Cheap: one grouped scan of the | |
| 293 | - * (capped) usage table; called when a beacon is accepted and from GC. | |
| 358 | + * and we can skip per-route checks for it. Stored with the faces it renders | |
| 359 | + * in, so preload stays weight-level. Cheap: one grouped scan of the (capped) | |
| 360 | + * usage table; called when a beacon is accepted and from GC. | |
| 294 | 361 | * |
| 295 | 362 | * @return void |
| 296 | 363 | */ |
| 297 | 364 | public function recompute_global_preload(): void { |
| @@ -297,18 +364,37 @@ | ||
| 297 | 364 | public function recompute_global_preload(): void { |
| 298 | 365 | global $wpdb; |
| 299 | 366 | |
| 300 | 367 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared |
| 301 | - $families = $wpdb->get_col( | |
| 302 | - "SELECT family FROM {$this->usage_table()} | |
| 368 | + $rows = $wpdb->get_results( | |
| 369 | + "SELECT family, weight, style, MIN(above_fold) AS af FROM {$this->usage_table()} | |
| 303 | 370 | WHERE rendered = 1 |
| 304 | - GROUP BY family | |
| 305 | - HAVING MIN(above_fold) = 1" | |
| 371 | + GROUP BY family, weight, style", | |
| 372 | + ARRAY_A | |
| 306 | 373 | ); |
| 307 | 374 | |
| 308 | - $families = $families ? array_values( array_unique( array_map( 'strtolower', $families ) ) ) : array(); | |
| 375 | + $faces = array(); | |
| 376 | + $below = array(); | |
| 309 | 377 | |
| 310 | - update_option( 'easyfonts_global_preload', $families, true ); | |
| 378 | + foreach ( (array) $rows as $r ) { | |
| 379 | + $fam = strtolower( (string) $r['family'] ); | |
| 380 | + | |
| 381 | + if ( empty( $r['af'] ) ) { | |
| 382 | + $below[ $fam ] = true; | |
| 383 | + } | |
| 384 | + | |
| 385 | + $faces[ $fam ][ $r['weight'] . '|' . $r['style'] ] = true; | |
| 386 | + } | |
| 387 | + | |
| 388 | + $global = array(); | |
| 389 | + | |
| 390 | + foreach ( $faces as $fam => $set ) { | |
| 391 | + if ( ! isset( $below[ $fam ] ) ) { | |
| 392 | + $global[ $fam ] = array_keys( $set ); | |
| 393 | + } | |
| 394 | + } | |
| 395 | + | |
| 396 | + update_option( 'easyfonts_global_preload', $global, true ); | |
| 311 | 397 | } |
| 312 | 398 | |
| 313 | 399 | /** |
| 314 | 400 | * Forget the stored decision for a route (all devices), so the next beacon |
| @@ -339,24 +425,26 @@ | ||
| 339 | 425 | global $wpdb; |
| 340 | 426 | |
| 341 | 427 | $key = sha1( $route . '|' . $device ); |
| 342 | 428 | |
| 429 | + $alias = $this->dup_alias(); | |
| 430 | + | |
| 343 | 431 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery |
| 344 | 432 | $wpdb->query( |
| 345 | 433 | $wpdb->prepare( |
| 346 | 434 | "INSERT INTO {$this->decisions_table()} |
| 347 | 435 | (route_key, route, device, preload, unload, updated_at) |
| 348 | - VALUES (%s,%s,%s,%s,%s,%s) | |
| 436 | + VALUES (%s,%s,%s,%s,%s,%s){$alias} | |
| 349 | 437 | ON DUPLICATE KEY UPDATE |
| 350 | - preload = VALUES(preload), | |
| 351 | - unload = VALUES(unload), | |
| 352 | - updated_at = VALUES(updated_at)", | |
| 438 | + preload = {$this->dup( 'preload' )}, | |
| 439 | + unload = {$this->dup( 'unload' )}, | |
| 440 | + updated_at = {$this->dup( 'updated_at' )}", | |
| 353 | 441 | $key, |
| 354 | 442 | $route, |
| 355 | 443 | $device, |
| 356 | 444 | wp_json_encode( array_values( $preload ) ), |
| 357 | 445 | wp_json_encode( array_values( $unload ) ), |
| 358 | - current_time( 'mysql' ) | |
| 446 | + gmdate( 'Y-m-d H:i:s' ) // UTC — parsed as UTC in has_fresh_decision(). | |
| 359 | 447 | ) |
| 360 | 448 | ); |
| 361 | 449 | } |
| 362 | 450 | |
| @@ -457,19 +545,20 @@ | ||
| 457 | 545 | $days = max( 1, $days ); |
| 458 | 546 | $usage = $this->usage_table(); |
| 459 | 547 | $dec = $this->decisions_table(); |
| 460 | 548 | |
| 461 | - // 1. Time-based prune (usage + decisions). | |
| 549 | + // 1. Time-based prune (usage + decisions). Timestamps are stored in | |
| 550 | + // UTC, so compare against UTC_TIMESTAMP() (not session-TZ NOW()). | |
| 462 | 551 | // phpcs:disable WordPress.DB.DirectDatabaseQuery |
| 463 | 552 | $wpdb->query( |
| 464 | 553 | $wpdb->prepare( |
| 465 | - "DELETE FROM {$usage} WHERE last_seen IS NOT NULL AND last_seen < ( NOW() - INTERVAL %d DAY )", | |
| 554 | + "DELETE FROM {$usage} WHERE last_seen IS NOT NULL AND last_seen < ( UTC_TIMESTAMP() - INTERVAL %d DAY )", | |
| 466 | 555 | $days |
| 467 | 556 | ) |
| 468 | 557 | ); |
| 469 | 558 | $wpdb->query( |
| 470 | 559 | $wpdb->prepare( |
| 471 | - "DELETE FROM {$dec} WHERE updated_at IS NOT NULL AND updated_at < ( NOW() - INTERVAL %d DAY )", | |
| 560 | + "DELETE FROM {$dec} WHERE updated_at IS NOT NULL AND updated_at < ( UTC_TIMESTAMP() - INTERVAL %d DAY )", | |
| 472 | 561 | $days |
| 473 | 562 | ) |
| 474 | 563 | ); |
| 475 | 564 | // phpcs:enable |
| @@ -474,8 +563,10 @@ | ||
| 474 | 563 | ); |
| 475 | 564 | // phpcs:enable |
| 476 | 565 | |
| 477 | 566 | // 2. Hard cap: if usage still exceeds the cap, drop the oldest surplus. |
| 567 | + // The id-bounded form is deterministic (replication-safe), unlike a bare | |
| 568 | + // DELETE ... ORDER BY ... LIMIT. | |
| 478 | 569 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared |
| 479 | 570 | $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$usage}" ); |
| 480 | 571 | |
| 481 | 572 | if ( $total > $max_rows ) { |
| @@ -483,14 +574,40 @@ | ||
| 483 | 574 | |
| 484 | 575 | // phpcs:ignore WordPress.DB.DirectDatabaseQuery |
| 485 | 576 | $wpdb->query( |
| 486 | 577 | $wpdb->prepare( |
| 487 | - "DELETE FROM {$usage} ORDER BY last_seen ASC LIMIT %d", | |
| 578 | + "DELETE FROM {$usage} WHERE id IN ( | |
| 579 | + SELECT id FROM ( SELECT id FROM {$usage} ORDER BY last_seen ASC, id ASC LIMIT %d ) AS ef_tmp | |
| 580 | + )", | |
| 488 | 581 | $surplus |
| 489 | 582 | ) |
| 490 | 583 | ); |
| 491 | 584 | } |
| 492 | 585 | |
| 586 | + // 3. Hard cap on decisions rows too — beacon routes are public input, so | |
| 587 | + // without a cap the table could be grown without bound between prunes. | |
| 588 | + /** | |
| 589 | + * Hard cap on decision rows after time-based pruning. | |
| 590 | + * | |
| 591 | + * @param int $max | |
| 592 | + */ | |
| 593 | + $max_dec = max( 100, (int) apply_filters( 'easyfonts_gc_max_decision_rows', 2000 ) ); | |
| 594 | + | |
| 595 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared | |
| 596 | + $total_dec = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$dec}" ); | |
| 597 | + | |
| 598 | + if ( $total_dec > $max_dec ) { | |
| 599 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery | |
| 600 | + $wpdb->query( | |
| 601 | + $wpdb->prepare( | |
| 602 | + "DELETE FROM {$dec} WHERE id IN ( | |
| 603 | + SELECT id FROM ( SELECT id FROM {$dec} ORDER BY updated_at ASC, id ASC LIMIT %d ) AS ef_tmp | |
| 604 | + )", | |
| 605 | + $total_dec - $max_dec | |
| 606 | + ) | |
| 607 | + ); | |
| 608 | + } | |
| 609 | + | |
| 493 | 610 | // Pruning may have changed which families are uniformly above the fold. |
| 494 | 611 | $this->recompute_global_preload(); |
| 495 | 612 | } |
| 496 | 613 | |
| @@ -522,9 +639,54 @@ | ||
| 522 | 639 | if ( ! $updated ) { |
| 523 | 640 | return false; |
| 524 | 641 | } |
| 525 | 642 | |
| 526 | - return ( time() - strtotime( (string) $updated ) ) < ( max( 1, $fresh_hours ) * HOUR_IN_SECONDS ); | |
| 643 | + // Stored in UTC — parse it as UTC regardless of PHP's default timezone. | |
| 644 | + return ( time() - strtotime( (string) $updated . ' UTC' ) ) < ( max( 1, $fresh_hours ) * HOUR_IN_SECONDS ); | |
| 645 | + } | |
| 646 | + | |
| 647 | + /** | |
| 648 | + * ON DUPLICATE KEY UPDATE row reference, portable across servers. | |
| 649 | + * | |
| 650 | + * MySQL 8.0.20 deprecated VALUES() in ON DUPLICATE KEY UPDATE in favour of | |
| 651 | + * a row alias — which MariaDB does not support. Detect the server once and | |
| 652 | + * emit whichever syntax is native, so neither engine logs deprecation | |
| 653 | + * warnings nor breaks when VALUES() is eventually removed. | |
| 654 | + * | |
| 655 | + * @param string $column Column name. | |
| 656 | + * @return string SQL fragment referencing the incoming row's value. | |
| 657 | + */ | |
| 658 | + private function dup( string $column ): string { | |
| 659 | + return $this->use_row_alias() ? "ef_new.{$column}" : "VALUES({$column})"; | |
| 660 | + } | |
| 661 | + | |
| 662 | + /** | |
| 663 | + * Row-alias suffix for the INSERT (empty when using VALUES()). | |
| 664 | + * | |
| 665 | + * @return string | |
| 666 | + */ | |
| 667 | + private function dup_alias(): string { | |
| 668 | + return $this->use_row_alias() ? ' AS ef_new' : ''; | |
| 669 | + } | |
| 670 | + | |
| 671 | + /** | |
| 672 | + * Should upserts use the MySQL 8.0.19+ row-alias syntax? | |
| 673 | + * | |
| 674 | + * @return bool | |
| 675 | + */ | |
| 676 | + private function use_row_alias(): bool { | |
| 677 | + static $use = null; | |
| 678 | + | |
| 679 | + if ( null === $use ) { | |
| 680 | + global $wpdb; | |
| 681 | + | |
| 682 | + $info = method_exists( $wpdb, 'db_server_info' ) ? (string) $wpdb->db_server_info() : ''; | |
| 683 | + $is_mariadb = false !== stripos( $info, 'mariadb' ); | |
| 684 | + | |
| 685 | + $use = ! $is_mariadb && '' !== $info && version_compare( (string) $wpdb->db_version(), '8.0.19', '>=' ); | |
| 686 | + } | |
| 687 | + | |
| 688 | + return $use; | |
| 527 | 689 | } |
| 528 | 690 | |
| 529 | 691 | /** |
| 530 | 692 | * Empty both tables. |