PluginProbe
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant / 2.0.4
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant v2.0.4
2.0.4 2.0.3 2.0.2 2.0.1 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2 1.3 2.0.0
easyfonts / src / Fonts / UsageTracker.php

UsageTracker.php in EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant 2.0.4, at src/Fonts/UsageTracker.php

706 lines 21.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Font usage + beacon decisions.
4 *
5 * @package EasyFonts
6 */
7
8 namespace EasyFonts\Fonts;
9
10 defined( 'ABSPATH' ) || exit;
11
12 /**
13 * Records real-render font usage and stores beacon-derived decisions.
14 */
15 class UsageTracker {
16
17 /**
18 * Usage table.
19 *
20 * @return string
21 */
22 private function usage_table(): string {
23 global $wpdb;
24
25 return $wpdb->prefix . 'easyfonts_usage';
26 }
27
28 /**
29 * Decisions table.
30 *
31 * @return string
32 */
33 private function decisions_table(): string {
34 global $wpdb;
35
36 return $wpdb->prefix . 'easyfonts_decisions';
37 }
38
39 /**
40 * Canonical route key for a URL: the path only, with the query string
41 * dropped. This is the single normalization used everywhere a page is
42 * identified (usage rows, beacon decisions, warm locks), so the server
43 * buffer and the browser beacon always agree on one key per page — and
44 * volatile params (cache-busters like efbust, the easyfonts_probe key,
45 * builder flags, tracking params) can never mint duplicate rows.
46 *
47 * @param string $url Absolute or relative URL.
48 * @return string Path beginning with '/', never empty.
49 */
50 public static function route_for( string $url ): string {
51 $path = (string) wp_parse_url( $url, PHP_URL_PATH );
52
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;
60 }
61
62 /**
63 * Canonical route key for the current request.
64 *
65 * @return string
66 */
67 public static function current_route(): string {
68 return self::route_for( home_url( add_query_arg( array() ) ) );
69 }
70
71 /**
72 * Record a set of font observations for one page in a single query each.
73 *
74 * @param string $page_url Page URL.
75 * @param int $page_id Post ID (0 if unknown).
76 * @param array<int,array<string,mixed>> $records Each: family, weight, style, origin, rendered, above_fold.
77 */
78 public function record( string $page_url, int $page_id, array $records ): void {
79 global $wpdb;
80
81 if ( empty( $records ) ) {
82 return;
83 }
84
85 $now = gmdate( 'Y-m-d H:i:s' ); // UTC — compared against UTC_TIMESTAMP() in gc().
86 $table = $this->usage_table();
87
88 // Collapse to one row per (page, family, weight, style); merge any
89 // duplicates within this batch (strongest rendered/above_fold wins,
90 // beacon origin beats buffer) so a single multi-row upsert replaces what
91 // used to be one query per variant on every render.
92 $by_key = array();
93
94 foreach ( $records as $rec ) {
95 $family = sanitize_text_field( $rec['family'] ?? '' );
96
97 if ( '' === $family ) {
98 continue;
99 }
100
101 $weight = sanitize_text_field( $rec['weight'] ?? '400' );
102 $style = sanitize_text_field( $rec['style'] ?? 'normal' );
103 $origin = sanitize_text_field( $rec['origin'] ?? 'buffer' );
104 $key = sha1( $page_url . '|' . strtolower( $family ) . '|' . $weight . '|' . $style );
105
106 $rendered = ! empty( $rec['rendered'] ) ? 1 : 0;
107 $above_fold = ! empty( $rec['above_fold'] ) ? 1 : 0;
108
109 if ( isset( $by_key[ $key ] ) ) {
110 $by_key[ $key ]['rendered'] = max( $by_key[ $key ]['rendered'], $rendered );
111 $by_key[ $key ]['above_fold'] = max( $by_key[ $key ]['above_fold'], $above_fold );
112
113 if ( 'beacon' === $origin ) {
114 $by_key[ $key ]['origin'] = 'beacon';
115 }
116
117 continue;
118 }
119
120 $by_key[ $key ] = array(
121 'key' => $key,
122 'family' => $family,
123 'weight' => $weight,
124 'style' => $style,
125 'origin' => $origin,
126 'rendered' => $rendered,
127 'above_fold' => $above_fold,
128 );
129 }
130
131 if ( empty( $by_key ) ) {
132 return;
133 }
134
135 $placeholders = array();
136 $args = array();
137
138 foreach ( $by_key as $row ) {
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)';
144 array_push(
145 $args,
146 $row['key'],
147 $page_url,
148 $page_id,
149 $row['family'],
150 $row['weight'],
151 $row['style'],
152 $row['origin'],
153 $row['rendered'],
154 $row['above_fold'],
155 $miss,
156 $now
157 );
158 }
159
160 $alias = $this->dup_alias();
161
162 $sql = "INSERT INTO {$table}
163 (usage_key, page_url, page_id, family, weight, style, origin, rendered, above_fold, hits, beacon_misses, last_seen)
164 VALUES " . implode( ',', $placeholders ) . "{$alias}
165 ON DUPLICATE KEY UPDATE
166 hits = hits + 1,
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' )}";
176
177 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
178 $wpdb->query( $wpdb->prepare( $sql, $args ) );
179 }
180
181 /**
182 * Sets of what the beacon observed actually rendering, for used/unused
183 * classification on the Fonts screen.
184 *
185 * @return array{variants:array<string,bool>,families:array<string,bool>}
186 */
187 public function rendered_set(): array {
188 global $wpdb;
189
190 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
191 $rows = $wpdb->get_results(
192 "SELECT DISTINCT family, weight, style FROM {$this->usage_table()} WHERE rendered = 1",
193 ARRAY_A
194 );
195
196 $variants = array();
197 $families = array();
198
199 if ( $rows ) {
200 foreach ( $rows as $r ) {
201 $lc = strtolower( $r['family'] );
202 $families[ $lc ] = true;
203 $variants[ $lc . '|' . $r['weight'] . '|' . $r['style'] ] = true;
204 }
205 }
206
207 return array(
208 'variants' => $variants,
209 'families' => $families,
210 );
211 }
212
213 /**
214 * Per-route render profile in a SINGLE query: which families the beacon has
215 * seen rendering on the route, and which of those are above the fold. Both
216 * are family-level and based on the page's authorial font-family, so they're
217 * robust to weight/style differences and self-correcting.
218 *
219 * - `rendered` drives page scoping (which fonts load on this page).
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.
226 *
227 * Empty `rendered` means the route hasn't been measured yet — callers must
228 * neither scope nor preload from no data.
229 *
230 * @param string $route Canonical route (path).
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}
232 */
233 public function route_render_profile( string $route ): array {
234 global $wpdb;
235
236 $out = array(
237 'rendered' => array(),
238 'above_fold' => array(),
239 'fold_faces' => array(),
240 'scope_out' => array(),
241 'measured' => false,
242 );
243
244 if ( '' === $route ) {
245 return $out;
246 }
247
248 // Per family on this route: was it ever rendered (used), was it above
249 // the fold, and has a real-browser beacon ever reported on it. The
250 // beacon reports both what rendered AND what loaded-but-never-rendered
251 // (recorded as rendered = 0, origin = 'beacon'), so a family is a
252 // confirmed scope-out ONLY when a beacon has actually measured it and
253 // never saw it render. A family that hasn't been beacon-measured yet
254 // (e.g. a font just added/replaced in the Customizer) is NEVER scoped
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.
257 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
258 $rows = $wpdb->get_results(
259 $wpdb->prepare(
260 "SELECT family, weight, style,
261 MAX(rendered) AS rnd,
262 MAX(CASE WHEN rendered = 1 THEN above_fold ELSE 0 END) AS af,
263 MAX(CASE WHEN origin = 'beacon' THEN 1 ELSE 0 END) AS beaconed,
264 MAX(beacon_misses) AS misses
265 FROM {$this->usage_table()}
266 WHERE page_url = %s
267 GROUP BY family, weight, style",
268 $route
269 ),
270 ARRAY_A
271 );
272
273 $families = array();
274
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() );
278
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'] );
283
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;
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;
320 }
321 }
322
323 return $out;
324 }
325
326 /**
327 * Cached set of families that are above the fold on EVERY route where they
328 * render — i.e. uniformly above-the-fold site-wide. These can be preloaded
329 * wherever they appear without consulting per-route data (and even before a
330 * given route has been measured), which is the cheap path: no per-route
331 * check needed for the fonts whose answer is the same everywhere.
332 *
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.
338 */
339 public function global_preload_faces(): array {
340 $list = get_option( 'easyfonts_global_preload', array() );
341 $out = array();
342
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;
352 }
353
354 /**
355 * Recompute and cache the uniformly-above-the-fold family set. A family
356 * qualifies when every measured row for it (across all routes/variants where
357 * it renders) is above the fold — so its preload answer doesn't vary by page
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.
361 *
362 * @return void
363 */
364 public function recompute_global_preload(): void {
365 global $wpdb;
366
367 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
368 $rows = $wpdb->get_results(
369 "SELECT family, weight, style, MIN(above_fold) AS af FROM {$this->usage_table()}
370 WHERE rendered = 1
371 GROUP BY family, weight, style",
372 ARRAY_A
373 );
374
375 $faces = array();
376 $below = array();
377
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 );
397 }
398
399 /**
400 * Forget the stored decision for a route (all devices), so the next beacon
401 * for it is accepted immediately instead of being throttled. Used by the
402 * "Optimize" action to force a fresh measurement on demand.
403 *
404 * @param string $route Canonical route (path).
405 * @return void
406 */
407 public function forget_decision( string $route ): void {
408 global $wpdb;
409
410 foreach ( array( 'mobile', 'desktop', 'any' ) as $dev ) {
411 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
412 $wpdb->delete( $this->decisions_table(), array( 'route_key' => sha1( $route . '|' . $dev ) ) );
413 }
414 }
415
416 /**
417 * Store beacon decisions (preload / unload) for a route + device.
418 *
419 * @param string $route Path (e.g. /pricing).
420 * @param string $device 'mobile'|'desktop'|'any'.
421 * @param array<int,mixed> $preload Above-the-fold variants to preload.
422 * @param array<int,mixed> $unload Loaded-but-unrendered variants to unload.
423 */
424 public function store_decision( string $route, string $device, array $preload, array $unload ): void {
425 global $wpdb;
426
427 $key = sha1( $route . '|' . $device );
428
429 $alias = $this->dup_alias();
430
431 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
432 $wpdb->query(
433 $wpdb->prepare(
434 "INSERT INTO {$this->decisions_table()}
435 (route_key, route, device, preload, unload, updated_at)
436 VALUES (%s,%s,%s,%s,%s,%s){$alias}
437 ON DUPLICATE KEY UPDATE
438 preload = {$this->dup( 'preload' )},
439 unload = {$this->dup( 'unload' )},
440 updated_at = {$this->dup( 'updated_at' )}",
441 $key,
442 $route,
443 $device,
444 wp_json_encode( array_values( $preload ) ),
445 wp_json_encode( array_values( $unload ) ),
446 gmdate( 'Y-m-d H:i:s' ) // UTC — parsed as UTC in has_fresh_decision().
447 )
448 );
449 }
450
451 /**
452 * Get the decision for a route + device (falls back to 'any').
453 *
454 * @param string $route Path.
455 * @param string $device Device.
456 * @return array{preload:array,unload:array}
457 */
458 public function get_decision( string $route, string $device ): array {
459 global $wpdb;
460
461 $empty = array(
462 'preload' => array(),
463 'unload' => array(),
464 );
465
466 foreach ( array( $device, 'any' ) as $dev ) {
467 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
468 $row = $wpdb->get_row(
469 $wpdb->prepare(
470 "SELECT preload, unload FROM {$this->decisions_table()} WHERE route_key = %s",
471 sha1( $route . '|' . $dev )
472 ),
473 ARRAY_A
474 );
475
476 if ( $row ) {
477 return array(
478 'preload' => json_decode( (string) $row['preload'], true ) ?: array(),
479 'unload' => json_decode( (string) $row['unload'], true ) ?: array(),
480 );
481 }
482 }
483
484 return $empty;
485 }
486
487 /**
488 * Aggregated usage summary (one row per family) for the dashboard.
489 *
490 * @return array<int,array<string,mixed>>
491 */
492 public function summary(): array {
493 global $wpdb;
494
495 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
496 $rows = $wpdb->get_results(
497 "SELECT
498 family,
499 GROUP_CONCAT(DISTINCT weight ORDER BY weight SEPARATOR ',') AS weights,
500 SUM(hits) AS hits,
501 COUNT(DISTINCT page_url) AS pages,
502 MAX(rendered) AS rendered,
503 MAX(above_fold) AS above_fold,
504 MAX(last_seen) AS last_seen
505 FROM {$this->usage_table()}
506 GROUP BY family
507 ORDER BY hits DESC",
508 ARRAY_A
509 );
510
511 return $rows ?: array();
512 }
513
514 /**
515 * Families that were loaded but never observed rendering (cleanup candidates).
516 *
517 * @return string[]
518 */
519 public function never_rendered_families(): array {
520 global $wpdb;
521
522 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
523 $rows = $wpdb->get_col(
524 "SELECT family FROM {$this->usage_table()}
525 GROUP BY family
526 HAVING MAX(rendered) = 0"
527 );
528
529 return $rows ?: array();
530 }
531
532 /**
533 * Garbage-collect stale rows. Keeps the usage/decisions tables bounded on
534 * long-running / high-traffic sites: anything not seen within the retention
535 * window is dropped, and a hard row cap trims the oldest beyond it. Called
536 * from the weekly cleanup event.
537 *
538 * @param int $days Retention window in days.
539 * @param int $max_rows Hard cap on usage rows after time-pruning.
540 * @return void
541 */
542 public function gc( int $days = 30, int $max_rows = 5000 ): void {
543 global $wpdb;
544
545 $days = max( 1, $days );
546 $usage = $this->usage_table();
547 $dec = $this->decisions_table();
548
549 // 1. Time-based prune (usage + decisions). Timestamps are stored in
550 // UTC, so compare against UTC_TIMESTAMP() (not session-TZ NOW()).
551 // phpcs:disable WordPress.DB.DirectDatabaseQuery
552 $wpdb->query(
553 $wpdb->prepare(
554 "DELETE FROM {$usage} WHERE last_seen IS NOT NULL AND last_seen < ( UTC_TIMESTAMP() - INTERVAL %d DAY )",
555 $days
556 )
557 );
558 $wpdb->query(
559 $wpdb->prepare(
560 "DELETE FROM {$dec} WHERE updated_at IS NOT NULL AND updated_at < ( UTC_TIMESTAMP() - INTERVAL %d DAY )",
561 $days
562 )
563 );
564 // phpcs:enable
565
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.
569 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
570 $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$usage}" );
571
572 if ( $total > $max_rows ) {
573 $surplus = $total - $max_rows;
574
575 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
576 $wpdb->query(
577 $wpdb->prepare(
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 )",
581 $surplus
582 )
583 );
584 }
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
610 // Pruning may have changed which families are uniformly above the fold.
611 $this->recompute_global_preload();
612 }
613
614 /**
615 * Has a fresh decision already been recorded for this route + device?
616 *
617 * Used to make public beacon ingestion effectively write-once per
618 * route/device until the retention window elapses — capping DB writes and
619 * neutralising repeated-request abuse without losing any functionality.
620 *
621 * @param string $route Path.
622 * @param string $device Device.
623 * @param int $fresh_hours Consider a decision "fresh" for this many hours.
624 * @return bool
625 */
626 public function has_fresh_decision( string $route, string $device, int $fresh_hours = 168 ): bool {
627 global $wpdb;
628
629 $key = sha1( $route . '|' . $device );
630
631 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
632 $updated = $wpdb->get_var(
633 $wpdb->prepare(
634 "SELECT updated_at FROM {$this->decisions_table()} WHERE route_key = %s",
635 $key
636 )
637 );
638
639 if ( ! $updated ) {
640 return false;
641 }
642
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;
689 }
690
691 /**
692 * Empty both tables.
693 */
694 public function truncate(): void {
695 global $wpdb;
696
697 // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
698 $wpdb->query( "TRUNCATE TABLE {$this->usage_table()}" );
699 $wpdb->query( "TRUNCATE TABLE {$this->decisions_table()}" );
700 // phpcs:enable
701
702 // No usage data left → no uniformly-above-the-fold fonts.
703 delete_option( 'easyfonts_global_preload' );
704 }
705 }
706