PluginProbe
WebberZone Top 10 — Popular Posts / trunk
WebberZone Top 10 — Popular Posts vtrunk
4.5.0 4.4.3 4.4.2 4.4.1 4.4.0 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 trunk 1.0 1.0.1 1.1 1.2 1.3 1.4 1.4.1 1.5 1.5.1 1.5.2 1.5.3 1.6 1.6.1 1.6.2 All 116 releases
top-10 / includes / class-database.php

class-database.php in WebberZone Top 10 — Popular Posts trunk, at includes/class-database.php

1,778 lines 64.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Database class.
4 *
5 * @package WebberZone\Top_Ten
6 */
7
8 namespace WebberZone\Top_Ten;
9
10 use WebberZone\Top_Ten\Util\Helpers;
11
12 /**
13 * Database operations class.
14 *
15 * @since 4.2.0
16 */
17 class Database {
18
19 /**
20 * Cached status of the required Top 10 tables for this request.
21 *
22 * @var array<string,bool>
23 */
24 private static $table_installation_cache = array();
25
26 /**
27 * Database version used to populate the request cache.
28 *
29 * @var string|null
30 */
31 private static $table_installation_cache_version = null;
32
33 /**
34 * Cached status of non-standard tables checked during this request.
35 *
36 * @var array<string,bool>
37 */
38 private static $individual_table_cache = array();
39
40 /**
41 * Cached table metadata for this request.
42 *
43 * @var array<string,array<string,array<string,int|string>>>
44 */
45 private static $table_metadata_cache = array();
46
47 /**
48 * Constructor.
49 */
50 public function __construct() {
51 // No initialization needed for static methods.
52 }
53
54 /**
55 * Get the table name for overall or daily counts.
56 *
57 * @since 4.2.0
58 *
59 * @param bool $daily Whether to get the daily table.
60 * @return string Table name.
61 */
62 public static function get_table( $daily = false ) {
63 global $wpdb;
64
65 $table_name = $wpdb->base_prefix . 'top_ten';
66 if ( $daily ) {
67 $table_name .= '_daily';
68 }
69 return $table_name;
70 }
71
72 /**
73 * Get count for a specific post.
74 *
75 * @since 4.2.0
76 *
77 * @param int $post_id Post ID.
78 * @param int $blog_id Blog ID (optional, defaults to current blog).
79 * @param bool $daily Whether to get daily count.
80 * @param array $date_range Date range array for daily counts ['from_date', 'to_date'].
81 * @return int Post count.
82 */
83 public static function get_count( $post_id, $blog_id = null, $daily = false, $date_range = array() ) {
84 global $wpdb;
85
86 $blog_id = $blog_id ?? get_current_blog_id();
87 $table = self::get_table( $daily );
88
89 if ( $daily && ! empty( $date_range ) ) {
90 $where = $wpdb->prepare( 'WHERE postnumber = %d AND blog_id = %d', $post_id, $blog_id );
91
92 if ( ! empty( $date_range['from_date'] ) ) {
93 $where .= $wpdb->prepare( ' AND dp_date >= %s', $date_range['from_date'] );
94 }
95 if ( ! empty( $date_range['to_date'] ) ) {
96 $where .= $wpdb->prepare( ' AND dp_date <= %s', $date_range['to_date'] );
97 }
98
99 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
100 $sql = "SELECT SUM(cntaccess) FROM {$table} {$where}";
101 } else {
102 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
103 $sql = $wpdb->prepare( "SELECT cntaccess FROM {$table} WHERE postnumber = %d AND blog_id = %d", $post_id, $blog_id );
104 }
105
106 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
107 return (int) $wpdb->get_var( $sql );
108 }
109
110 /**
111 * Get the count for a site-wide request context.
112 *
113 * Site-wide contexts are resolved by the Pro-only fixed-ID map and then
114 * addressed by their reserved numeric ID in the shared count tables.
115 *
116 * @since 4.5.0
117 *
118 * @param string $context Site-wide context key.
119 * @param int|null $blog_id Blog ID (optional, defaults to current blog).
120 * @param bool $daily Whether to get the daily count.
121 * @param array $date_range Date range array for daily counts.
122 * @return int Site-wide count.
123 */
124 public static function get_sitewide_count( $context, $blog_id = null, $daily = false, $date_range = array() ) {
125 /**
126 * Filters the view count for a site-wide context.
127 *
128 * Site-wide contexts are a Pro feature; the free plugin always returns 0.
129 *
130 * @since 4.5.0
131 *
132 * @param int $count The site-wide count.
133 * @param string $context Site-wide context key.
134 * @param int|null $blog_id Blog ID, or null for the current blog.
135 * @param bool $daily Whether a daily count was requested.
136 * @param array $date_range Date range used for daily counts.
137 */
138 return (int) apply_filters( 'tptn_get_sitewide_count', 0, $context, $blog_id, $daily, $date_range );
139 }
140
141 /**
142 * Update count for a post.
143 *
144 * @since 4.2.0
145 * @deprecated 4.3.0 Use {@see Database::append_to_funnel()} instead.
146 *
147 * @param int $post_id Post ID.
148 * @param int $blog_id Blog ID (optional, defaults to current blog).
149 * @param bool $daily Whether to update daily count.
150 * @return int|false Number of rows affected or false on error.
151 */
152 public static function update_count( $post_id, $blog_id = null, $daily = false ) {
153 _deprecated_function( __METHOD__, '4.3.0', 'Database::append_to_funnel()' );
154
155 $blog_id = $blog_id ?? get_current_blog_id();
156 $activate_counter = $daily ? 10 : 1;
157
158 return self::append_to_funnel( $post_id, $blog_id, $activate_counter );
159 }
160
161 /**
162 * Set count for a post to a specific value.
163 *
164 * @since 4.2.0
165 *
166 * @param int $post_id Post ID.
167 * @param int $count Count value to set.
168 * @param int $blog_id Blog ID (optional, defaults to current blog).
169 * @param bool $daily Whether to update daily count.
170 * @return int|false Number of rows affected or false on error.
171 */
172 public static function set_count( $post_id, $count, $blog_id = null, $daily = false ) {
173 global $wpdb;
174
175 $blog_id = $blog_id ?? get_current_blog_id();
176 $table = self::get_table( $daily );
177
178 if ( $daily ) {
179 $dp_date = current_time( 'Y-m-d H' );
180 $sql = $wpdb->prepare(
181 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
182 "INSERT INTO {$table} (postnumber, cntaccess, dp_date, blog_id) VALUES (%d, %d, %s, %d) ON DUPLICATE KEY UPDATE cntaccess = %d",
183 $post_id,
184 $count,
185 $dp_date,
186 $blog_id,
187 $count
188 );
189 } else {
190 $sql = $wpdb->prepare(
191 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
192 "INSERT INTO {$table} (postnumber, cntaccess, blog_id) VALUES (%d, %d, %d) ON DUPLICATE KEY UPDATE cntaccess = %d",
193 $post_id,
194 $count,
195 $blog_id,
196 $count
197 );
198 }
199
200 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
201 $result = $wpdb->query( $sql );
202
203 // Trigger action to clear cache.
204 if ( false !== $result ) {
205 /**
206 * Fires after a post's view count has been written to the database.
207 *
208 * @since 4.2.0
209 *
210 * @param int $post_id Post ID.
211 * @param int $count The count that was stored.
212 * @param int $blog_id Blog ID.
213 * @param bool $daily Whether the daily table was updated.
214 */
215 do_action( 'tptn_set_count', $post_id, $count, $blog_id, $daily );
216 }
217
218 return $result;
219 }
220
221 /**
222 * Delete counts based on criteria.
223 *
224 * @since 4.2.0
225 *
226 * @param array $args {
227 * Optional. Array of arguments.
228 *
229 * @type array $post_ids Array of post IDs to delete.
230 * @type int $blog_id Blog ID to delete from.
231 * @type string $from_date Delete entries from this date (daily table only).
232 * @type string $to_date Delete entries until this date (daily table only).
233 * @type bool $daily Whether to delete from daily table.
234 * @type int $limit Maximum number of rows to delete per call (0 = no limit).
235 * }
236 * @return int|false Number of rows deleted or false on error.
237 */
238 public static function delete_counts( $args = array() ) {
239 global $wpdb;
240
241 $defaults = array(
242 'post_ids' => array(),
243 'blog_id' => null,
244 'from_date' => '',
245 'to_date' => '',
246 'daily' => false,
247 'limit' => 0,
248 );
249 $args = wp_parse_args( $args, $defaults );
250
251 $table = self::get_table( $args['daily'] );
252 $where = array();
253
254 if ( ! empty( $args['post_ids'] ) ) {
255 $post_ids = array_map( 'intval', $args['post_ids'] );
256 $where[] = 'postnumber IN (' . implode( ',', $post_ids ) . ')';
257 }
258
259 if ( null !== $args['blog_id'] ) {
260 $where[] = $wpdb->prepare( 'blog_id = %d', $args['blog_id'] );
261 }
262
263 if ( $args['daily'] ) {
264 if ( ! empty( $args['from_date'] ) ) {
265 $where[] = $wpdb->prepare( 'dp_date >= %s', $args['from_date'] );
266 }
267 if ( ! empty( $args['to_date'] ) ) {
268 $where[] = $wpdb->prepare( 'dp_date <= %s', $args['to_date'] );
269 }
270 }
271
272 $sql = "DELETE FROM {$table}";
273 if ( ! empty( $where ) ) {
274 $sql .= ' WHERE ' . implode( ' AND ', $where );
275 }
276
277 if ( ! empty( $args['limit'] ) && $args['limit'] > 0 && ! empty( $where ) ) {
278 $sql .= $wpdb->prepare( ' LIMIT %d', $args['limit'] );
279 }
280
281 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
282 $result = $wpdb->query( $sql );
283
284 // Trigger action to clear cache.
285 if ( false !== $result ) {
286 /**
287 * Fires after view counts have been deleted from the database.
288 *
289 * @since 4.2.0
290 *
291 * @param array $args Arguments describing which counts were deleted.
292 */
293 do_action( 'tptn_delete_counts', $args );
294 }
295
296 return $result;
297 }
298
299 /**
300 * Get estimated table statistics including entry count and size.
301 *
302 * @since 4.2.0
303 *
304 * @return array Array of table statistics with entry count and size.
305 */
306 public static function get_table_statistics() {
307 $cache_key = 'tptn_table_statistics';
308 $stats = wp_cache_get( $cache_key, 'top-10' );
309
310 if ( false === $stats ) {
311 $stats = is_multisite() ? get_site_transient( $cache_key ) : get_transient( $cache_key );
312 }
313
314 if ( false === $stats ) {
315 $tables = array(
316 'top_ten' => self::get_table( false ),
317 'top_ten_daily' => self::get_table( true ),
318 'top_ten_visits_funnel' => self::get_funnel_table(),
319 'top_ten_visits_log' => self::get_log_table(),
320 );
321 $metadata = self::get_table_metadata( array_values( $tables ) );
322 $stats = array();
323
324 foreach ( $tables as $key => $table_name ) {
325 if ( isset( $metadata[ $table_name ] ) ) {
326 $stats[ $key ] = array(
327 'entries' => $metadata[ $table_name ]['table_rows'],
328 'size' => $metadata[ $table_name ]['data_length'] + $metadata[ $table_name ]['index_length'],
329 'estimated' => true,
330 );
331 }
332 }
333
334 // Cache for 5 minutes. Network-wide table metadata is shared by all sites.
335 if ( is_multisite() ) {
336 set_site_transient( $cache_key, $stats, 5 * MINUTE_IN_SECONDS );
337 } else {
338 set_transient( $cache_key, $stats, 5 * MINUTE_IN_SECONDS );
339 }
340 }
341
342 wp_cache_set( $cache_key, $stats, 'top-10', 5 * MINUTE_IN_SECONDS );
343
344 /**
345 * Filter the table statistics.
346 *
347 * @since 4.2.0
348 *
349 * @param array $stats Array of table statistics.
350 */
351 return apply_filters( 'tptn_table_statistics', $stats );
352 }
353
354 /**
355 * Get table metadata for a set of tables.
356 *
357 * @since 4.5.0
358 *
359 * @param string[] $tables Tables to inspect.
360 * @param bool $force Whether to bypass the request cache.
361 * @return array<string,array<string,int|string>> Table metadata keyed by name.
362 */
363 private static function get_table_metadata( $tables, $force = false ) {
364 global $wpdb;
365
366 $tables = array_values( array_unique( array_filter( $tables, 'is_string' ) ) );
367 if ( empty( $tables ) ) {
368 return array();
369 }
370
371 $cache_key = implode( '|', $tables );
372 if ( ! $force && isset( self::$table_metadata_cache[ $cache_key ] ) ) {
373 return self::$table_metadata_cache[ $cache_key ];
374 }
375
376 $placeholders = implode( ', ', array_fill( 0, count( $tables ), '%s' ) );
377 if ( self::is_sqlite() ) {
378 $sql = $wpdb->prepare(
379 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
380 "SELECT name AS TABLE_NAME, 0 AS TABLE_ROWS, 0 AS DATA_LENGTH, 0 AS INDEX_LENGTH FROM sqlite_master WHERE type = 'table' AND name IN ({$placeholders})",
381 ...$tables
382 );
383 } else {
384 $sql = $wpdb->prepare(
385 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
386 "SELECT TABLE_NAME, TABLE_ROWS, DATA_LENGTH, INDEX_LENGTH FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ({$placeholders})",
387 ...$tables
388 );
389 }
390
391 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
392 $rows = $wpdb->get_results( $sql, ARRAY_A );
393 $metadata = array();
394 foreach ( $rows as $row ) {
395 $table_name = isset( $row['TABLE_NAME'] ) ? (string) $row['TABLE_NAME'] : '';
396 if ( '' !== $table_name && in_array( $table_name, $tables, true ) ) {
397 $metadata[ $table_name ] = array(
398 'table_rows' => absint( $row['TABLE_ROWS'] ?? 0 ),
399 'data_length' => absint( $row['DATA_LENGTH'] ?? 0 ),
400 'index_length' => absint( $row['INDEX_LENGTH'] ?? 0 ),
401 );
402 }
403 }
404
405 // Temporary tables are not exposed through information_schema or sqlite_master.
406 foreach ( array_diff( $tables, array_keys( $metadata ) ) as $table_name ) {
407 $query = $wpdb->prepare( 'SELECT 1 FROM %i LIMIT 0', $table_name );
408 $suppress_errors = $wpdb->suppress_errors();
409
410 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
411 $table_exists = false !== $wpdb->query( $query );
412 $wpdb->suppress_errors( $suppress_errors );
413
414 if ( $table_exists ) {
415 $metadata[ $table_name ] = array(
416 'table_rows' => 0,
417 'data_length' => 0,
418 'index_length' => 0,
419 );
420 }
421 }
422
423 self::$table_metadata_cache[ $cache_key ] = $metadata;
424
425 return $metadata;
426 }
427
428 /**
429 * Get installation status for a set of tables.
430 *
431 * @since 4.5.0
432 *
433 * @param string[] $tables Tables to inspect.
434 * @param bool $force Whether to bypass the request cache.
435 * @return array<string,bool> Table statuses keyed by name.
436 */
437 private static function get_table_statuses( $tables, $force = false ) {
438 $tables = array_values( array_unique( array_filter( $tables, 'is_string' ) ) );
439 $metadata = self::get_table_metadata( $tables, $force );
440 $statuses = array_fill_keys( $tables, false );
441
442 foreach ( array_keys( $metadata ) as $table ) {
443 $statuses[ $table ] = true;
444 }
445
446 return $statuses;
447 }
448
449 /**
450 * Determine whether the current database is SQLite.
451 *
452 * @since 4.5.0
453 *
454 * @return bool Whether SQLite is in use.
455 */
456 private static function is_sqlite() {
457 global $wpdb;
458
459 if ( defined( 'DATABASE_TYPE' ) && 'sqlite' === strtolower( (string) DATABASE_TYPE ) ) {
460 return true;
461 }
462
463 return method_exists( $wpdb, 'db_server_info' ) && false !== strpos( strtolower( (string) $wpdb->db_server_info() ), 'sqlite' );
464 }
465
466 /**
467 * Clear the table statistics cache.
468 *
469 * @since 4.2.0
470 */
471 public static function clear_table_statistics_cache() {
472 wp_cache_delete( 'tptn_table_statistics', 'top-10' );
473
474 if ( is_multisite() ) {
475 delete_site_transient( 'tptn_table_statistics' );
476 } else {
477 delete_transient( 'tptn_table_statistics' );
478 }
479 }
480
481 /**
482 * Invalidate the persistent and request-level table installation caches.
483 *
484 * @since 4.5.0
485 */
486 public static function clear_table_installation_cache() {
487 self::$table_installation_cache = array();
488 self::$table_installation_cache_version = null;
489 self::$individual_table_cache = array();
490 self::$table_metadata_cache = array();
491
492 delete_site_option( 'tptn_tables_installed' );
493 }
494
495 /**
496 * Get the installation status of the four required Top 10 tables.
497 *
498 * The status is persisted in a network option so normal admin requests do
499 * not need to enumerate the database tables. Explicit diagnostic requests
500 * can bypass the cache by setting $force to true.
501 *
502 * @since 4.5.0
503 *
504 * @param bool $force Whether to perform a live check.
505 * @return array<string,bool> Table names mapped to their installation status.
506 */
507 public static function get_table_installation_status( $force = false ) {
508 global $tptn_db_version;
509
510 $tables = array(
511 self::get_table( false ),
512 self::get_table( true ),
513 self::get_funnel_table(),
514 self::get_log_table(),
515 );
516 $version = isset( $tptn_db_version ) ? (string) $tptn_db_version : '';
517
518 $has_cached_tables = self::$table_installation_cache_version === $version && count( self::$table_installation_cache ) === count( $tables ) && ! array_diff_key( array_fill_keys( $tables, true ), self::$table_installation_cache );
519 if ( ! $force && $has_cached_tables ) {
520 return self::$table_installation_cache;
521 }
522
523 if ( ! $force ) {
524 $cached = get_site_option( 'tptn_tables_installed', array() );
525 $has_all_tables = is_array( $cached ) && isset( $cached['db_version'], $cached['tables'] ) && (string) $cached['db_version'] === $version && is_array( $cached['tables'] ) && ! array_diff_key( array_fill_keys( $tables, true ), $cached['tables'] );
526 if ( $has_all_tables ) {
527 self::$table_installation_cache = array();
528 self::$individual_table_cache = array();
529 foreach ( $tables as $table ) {
530 self::$table_installation_cache[ $table ] = (bool) $cached['tables'][ $table ];
531 }
532 self::$table_installation_cache_version = $version;
533
534 return self::$table_installation_cache;
535 }
536 }
537
538 $statuses = self::get_table_statuses( $tables, $force );
539
540 self::$table_installation_cache = $statuses;
541 self::$table_installation_cache_version = $version;
542 update_site_option(
543 'tptn_tables_installed',
544 array(
545 'db_version' => $version,
546 'tables' => $statuses,
547 )
548 );
549
550 return $statuses;
551 }
552
553 /**
554 * Check if a table exists.
555 *
556 * @since 4.2.0
557 *
558 * @param string $table Table name to check.
559 * @param bool $force Whether to perform a live check.
560 * @return bool True if table exists, false otherwise.
561 */
562 public static function is_table_installed( $table, $force = false ) {
563 $required_tables = array(
564 self::get_table( false ),
565 self::get_table( true ),
566 self::get_funnel_table(),
567 self::get_log_table(),
568 );
569
570 if ( in_array( $table, $required_tables, true ) ) {
571 $statuses = self::get_table_installation_status( $force );
572 return ! empty( $statuses[ $table ] );
573 }
574
575 if ( ! $force && array_key_exists( $table, self::$individual_table_cache ) ) {
576 return self::$individual_table_cache[ $table ];
577 }
578
579 $statuses = self::get_table_statuses( array( $table ), $force );
580 self::$individual_table_cache[ $table ] = ! empty( $statuses[ $table ] );
581
582 return self::$individual_table_cache[ $table ];
583 }
584
585 /**
586 * Get counts with post information (JOIN with wp_posts).
587 *
588 * @since 4.2.0
589 *
590 * @param array $args {
591 * Optional. Array of arguments.
592 *
593 * @type bool $daily Whether to get daily counts.
594 * @type int $blog_id Blog ID to filter by.
595 * @type string $from_date From date for daily counts.
596 * @type string $to_date To date for daily counts.
597 * @type int $limit Number of results to return.
598 * @type int $offset Offset for pagination.
599 * @type string $order Order direction (ASC/DESC).
600 * @type string $post_type Post type to filter by.
601 * @type array $post_ids Specific post IDs to include.
602 * }
603 * @return array Array of results with post and count information.
604 */
605 public static function get_counts_with_posts( $args = array() ) {
606 global $wpdb;
607
608 $defaults = array(
609 'daily' => false,
610 'blog_id' => null,
611 'from_date' => '',
612 'to_date' => '',
613 'limit' => 10,
614 'offset' => 0,
615 'order' => 'DESC',
616 'post_type' => 'post',
617 'post_ids' => array(),
618 );
619 $args = wp_parse_args( $args, $defaults );
620
621 $table = self::get_table( $args['daily'] );
622 $where = array();
623 $join = " LEFT JOIN {$wpdb->posts} ON t.postnumber = {$wpdb->posts}.ID ";
624 $select_col = $args['daily'] ? 'SUM(t.cntaccess) as cntaccess' : 't.cntaccess';
625
626 if ( null !== $args['blog_id'] ) {
627 $where[] = $wpdb->prepare( 't.blog_id = %d', $args['blog_id'] );
628 }
629
630 if ( $args['daily'] ) {
631 if ( ! empty( $args['from_date'] ) ) {
632 $where[] = $wpdb->prepare( 't.dp_date >= %s', gmdate( 'Y-m-d 00:00:00', strtotime( $args['from_date'] ) ) );
633 }
634 if ( ! empty( $args['to_date'] ) ) {
635 $where[] = $wpdb->prepare( 't.dp_date < %s', gmdate( 'Y-m-d 00:00:00', strtotime( $args['to_date'] . ' +1 day' ) ) );
636 }
637 }
638
639 if ( ! empty( $args['post_ids'] ) ) {
640 $post_ids = array_map( 'intval', $args['post_ids'] );
641 $where[] = 't.postnumber IN (' . implode( ',', $post_ids ) . ')';
642 }
643
644 $where[] = $wpdb->prepare( "{$wpdb->posts}.post_type = %s", $args['post_type'] );
645 $where[] = "{$wpdb->posts}.post_status = 'publish'";
646
647 $sql = "SELECT t.postnumber, {$select_col}, t.blog_id, {$wpdb->posts}.post_title, {$wpdb->posts}.post_date ";
648 $sql .= "FROM {$table} t {$join}";
649 $sql .= ' WHERE ' . implode( ' AND ', $where );
650
651 if ( $args['daily'] ) {
652 $sql .= " GROUP BY t.postnumber, t.blog_id, {$wpdb->posts}.post_title, {$wpdb->posts}.post_date ";
653 }
654
655 // Sanitize order parameter.
656 $order = in_array( strtoupper( $args['order'] ), array( 'ASC', 'DESC' ), true ) ? strtoupper( $args['order'] ) : 'DESC';
657 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
658 $sql .= $wpdb->prepare( " ORDER BY cntaccess {$order} LIMIT %d OFFSET %d", $args['limit'], $args['offset'] );
659
660 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
661 return $wpdb->get_results( $sql, ARRAY_A );
662 }
663
664 /**
665 * Bulk upsert counts for import operations.
666 *
667 * @since 4.2.0
668 *
669 * @param array $data Array of data to insert. Each element should be an array with postnumber, cntaccess, blog_id keys.
670 * @param bool $daily Whether this is for daily table (includes dp_date).
671 * @return int|false Number of rows affected or false on error.
672 */
673 public static function bulk_upsert( $data, $daily = false ) {
674 global $wpdb;
675
676 if ( empty( $data ) ) {
677 return false;
678 }
679
680 $table = self::get_table( $daily );
681 $values = array();
682
683 foreach ( $data as $row ) {
684 if ( $daily ) {
685 $dp_date = isset( $row['dp_date'] ) ? $row['dp_date'] : current_time( 'Y-m-d H' );
686 $values[] = $wpdb->prepare( '( %d, %d, %s, %d )', $row['postnumber'], $row['cntaccess'], $dp_date, $row['blog_id'] );
687 } else {
688 $values[] = $wpdb->prepare( '( %d, %d, %d )', $row['postnumber'], $row['cntaccess'], $row['blog_id'] );
689 }
690 }
691
692 if ( $daily ) {
693 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
694 $sql = "INSERT INTO {$table} (postnumber, cntaccess, dp_date, blog_id) VALUES " . implode( ',', $values ) . ' ON DUPLICATE KEY UPDATE cntaccess = VALUES(cntaccess)';
695 } else {
696 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
697 $sql = "INSERT INTO {$table} (postnumber, cntaccess, blog_id) VALUES " . implode( ',', $values ) . ' ON DUPLICATE KEY UPDATE cntaccess = VALUES(cntaccess)';
698 }
699
700 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
701 return $wpdb->query( $sql );
702 }
703
704 /**
705 * Get total count for all posts.
706 *
707 * @since 4.2.0
708 *
709 * @param int $blog_id Blog ID (optional, defaults to current blog).
710 * @param bool $daily Whether to get daily total.
711 * @param string $from_date From date for daily counts.
712 * @param string $to_date To date for daily counts.
713 * @return int Total count.
714 */
715 public static function get_total_count( $blog_id = null, $daily = false, $from_date = '', $to_date = '' ) {
716 global $wpdb;
717
718 $blog_id = $blog_id ?? get_current_blog_id();
719 $table = self::get_table( $daily );
720 $where = $wpdb->prepare( 'WHERE blog_id = %d', $blog_id );
721
722 if ( $daily ) {
723 if ( ! empty( $from_date ) ) {
724 $where .= $wpdb->prepare( ' AND dp_date >= %s', $from_date );
725 }
726 if ( ! empty( $to_date ) ) {
727 $where .= $wpdb->prepare( ' AND dp_date <= %s', $to_date );
728 }
729 }
730
731 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
732 $sql = "SELECT SUM(cntaccess) FROM {$table} {$where}";
733
734 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
735 return (int) $wpdb->get_var( $sql );
736 }
737
738 /**
739 * Get popular posts with caching support.
740 *
741 * @since 4.2.0
742 *
743 * @param array $args Query arguments (same format as Top_Ten_Core_Query).
744 * @return array Array of post IDs.
745 */
746 public static function get_popular_posts( $args = array() ) {
747 // This method integrates with the existing Top_Ten_Core_Query class
748 // but provides a simpler interface for basic operations.
749
750 $defaults = array(
751 'daily' => false,
752 'limit' => 10,
753 'post_type' => 'post',
754 'blog_id' => null,
755 );
756 $args = wp_parse_args( $args, $defaults );
757
758 // Use the existing query class for complex operations.
759 $query = new \Top_Ten_Query( $args );
760 $posts = $query->get_posts();
761
762 return wp_list_pluck( $posts, 'ID' );
763 }
764
765 /**
766 * Check if the Top Ten tables exist.
767 *
768 * @since 4.2.0
769 *
770 * @return bool True if both tables exist, false otherwise.
771 */
772 public static function are_tables_installed() {
773 $statuses = self::get_table_installation_status();
774
775 return ! empty( $statuses[ self::get_table( false ) ] )
776 && ! empty( $statuses[ self::get_table( true ) ] );
777 }
778
779 /**
780 * Create table SQL for the main top_ten table.
781 *
782 * @since 4.2.0
783 *
784 * @return string SQL to create the main table.
785 */
786 public static function create_full_table_sql() {
787 global $wpdb;
788
789 $charset_collate = $wpdb->get_charset_collate();
790 $table_name = $wpdb->base_prefix . 'top_ten';
791
792 $sql = "CREATE TABLE {$table_name}" . // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
793 " (
794 postnumber bigint(20) NOT NULL,
795 cntaccess bigint(20) NOT NULL,
796 blog_id bigint(20) NOT NULL DEFAULT '1',
797 PRIMARY KEY (postnumber, blog_id),
798 KEY idx_blog_id (blog_id),
799 KEY idx_cntaccess (cntaccess),
800 KEY idx_blog_cntaccess (blog_id, cntaccess)
801 ) $charset_collate;";
802
803 return $sql;
804 }
805
806 /**
807 * Create table SQL for the daily top_ten_daily table.
808 *
809 * @since 4.2.0
810 *
811 * @return string SQL to create the daily table.
812 */
813 public static function create_daily_table_sql() {
814 global $wpdb;
815
816 $charset_collate = $wpdb->get_charset_collate();
817 $table_name = $wpdb->base_prefix . 'top_ten_daily';
818
819 $sql = "CREATE TABLE {$table_name}" . // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
820 " (
821 postnumber bigint(20) NOT NULL,
822 cntaccess bigint(20) NOT NULL,
823 dp_date DATETIME NOT NULL,
824 blog_id bigint(20) NOT NULL DEFAULT '1',
825 PRIMARY KEY (postnumber, dp_date, blog_id),
826 KEY blog_date (blog_id, dp_date, postnumber),
827 KEY idx_dp_date (dp_date)
828 ) $charset_collate;";
829
830 return $sql;
831 }
832
833 /**
834 * Get the name of the visits funnel table (hot buffer, drained every 5 minutes).
835 *
836 * @since 4.3.0
837 *
838 * @return string Table name.
839 */
840 public static function get_funnel_table() {
841 global $wpdb;
842 return $wpdb->base_prefix . 'top_ten_visits_funnel';
843 }
844
845 /**
846 * SQL to create the visits funnel table.
847 *
848 * @since 4.3.0
849 *
850 * @return string CREATE TABLE SQL.
851 */
852 public static function create_funnel_table_sql() {
853 global $wpdb;
854
855 $charset_collate = $wpdb->get_charset_collate();
856 $table_name = self::get_funnel_table();
857
858 $sql = "CREATE TABLE {$table_name}" . // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
859 " (
860 id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
861 postnumber bigint(20) UNSIGNED NOT NULL,
862 blog_id bigint(20) UNSIGNED NOT NULL DEFAULT '1',
863 visited_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
864 activate_counter tinyint(2) UNSIGNED NOT NULL DEFAULT '11',
865 source tinyint(2) UNSIGNED NOT NULL DEFAULT '0',
866 PRIMARY KEY (id)
867 ) $charset_collate;";
868
869 return $sql;
870 }
871
872 /**
873 * Get the name of the visits log table (cold archive, pruned by maintenance cron).
874 *
875 * @since 4.3.0
876 *
877 * @return string Table name.
878 */
879 public static function get_log_table() {
880 global $wpdb;
881 return $wpdb->base_prefix . 'top_ten_visits_log';
882 }
883
884 /**
885 * SQL to create the visits log table.
886 *
887 * @since 4.3.0
888 *
889 * @return string CREATE TABLE SQL.
890 */
891 public static function create_log_table_sql() {
892 global $wpdb;
893
894 $charset_collate = $wpdb->get_charset_collate();
895 $table_name = self::get_log_table();
896
897 $sql = "CREATE TABLE {$table_name}" . // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
898 " (
899 id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
900 postnumber bigint(20) UNSIGNED NOT NULL,
901 blog_id bigint(20) UNSIGNED NOT NULL DEFAULT '1',
902 visited_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
903 source tinyint(2) UNSIGNED NOT NULL DEFAULT '0',
904 PRIMARY KEY (id),
905 KEY idx_visited_at (visited_at)
906 ) $charset_collate;";
907
908 return $sql;
909 }
910
911 /**
912 * Record a single visit using the configured tracking method.
913 *
914 * Funnel tracking (default) appends the visit to the funnel table which is
915 * drained into the count tables by the aggregation cron. Legacy tracking
916 * writes directly to the count tables on every visit (pre-4.3 behaviour)
917 * and does not populate the visits log.
918 *
919 * @since 4.3.3
920 *
921 * @param int $post_id Post ID.
922 * @param int $blog_id Blog ID.
923 * @param int $activate_counter Counter flag: 1 = overall, 10 = daily, 11 = both.
924 * @param int $source Traffic source: 0 = web, 1 = feed. Only stored by funnel tracking.
925 * @return int|false Rows inserted/updated or false on error.
926 */
927 public static function record_view( $post_id, $blog_id, $activate_counter = 11, $source = 0 ) {
928 if ( 'legacy' === \tptn_get_option( 'tracking_method', 'funnel' ) ) {
929 return self::update_counts_direct( $post_id, $blog_id, $activate_counter );
930 }
931
932 return self::append_to_funnel( $post_id, $blog_id, $activate_counter, $source );
933 }
934
935 /**
936 * Write a single visit directly to the overall and daily count tables.
937 *
938 * This is the legacy (pre-4.3) tracking method: an immediate upsert per view,
939 * bypassing the funnel table and the aggregation cron. The visits log is not
940 * populated by this method.
941 *
942 * @since 4.3.3
943 *
944 * @param int $post_id Post ID.
945 * @param int $blog_id Blog ID.
946 * @param int $activate_counter Counter flag: 1 = overall, 10 = daily, 11 = both.
947 * @return int|false Rows inserted/updated or false on error.
948 */
949 public static function update_counts_direct( $post_id, $blog_id, $activate_counter = 11 ) {
950 global $wpdb;
951
952 $post_id = absint( $post_id );
953 $blog_id = absint( $blog_id );
954 $activate_counter = (int) $activate_counter;
955 $rows = 0;
956
957 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
958 if ( in_array( $activate_counter, array( 1, 11 ), true ) ) {
959 $table = self::get_table( false );
960 $result = $wpdb->query(
961 $wpdb->prepare(
962 "INSERT INTO {$table} (postnumber, cntaccess, blog_id) VALUES (%d, 1, %d) ON DUPLICATE KEY UPDATE cntaccess = cntaccess + 1",
963 $post_id,
964 $blog_id
965 )
966 );
967 if ( false === $result ) {
968 self::clear_table_installation_cache();
969 return false;
970 }
971 $rows += (int) $result;
972 }
973
974 if ( in_array( $activate_counter, array( 10, 11 ), true ) ) {
975 $table = self::get_table( true );
976 $dp_date = current_time( 'Y-m-d H' ) . ':00:00';
977 $result = $wpdb->query(
978 $wpdb->prepare(
979 "INSERT INTO {$table} (postnumber, cntaccess, dp_date, blog_id) VALUES (%d, 1, %s, %d) ON DUPLICATE KEY UPDATE cntaccess = cntaccess + 1",
980 $post_id,
981 $dp_date,
982 $blog_id
983 )
984 );
985 if ( false === $result ) {
986 self::clear_table_installation_cache();
987 return false;
988 }
989 $rows += (int) $result;
990 }
991 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
992
993 return $rows;
994 }
995
996 /**
997 * Append a single visit to the funnel table.
998 *
999 * @since 4.3.0
1000 *
1001 * @param int $post_id Post ID.
1002 * @param int $blog_id Blog ID.
1003 * @param int $activate_counter Counter flag: 1 = overall, 10 = daily, 11 = both.
1004 * @param int $source Traffic source: 0 = web, 1 = feed.
1005 * @return int|false Rows inserted or false on error.
1006 */
1007 public static function append_to_funnel( $post_id, $blog_id, $activate_counter = 11, $source = 0 ) {
1008 global $wpdb;
1009
1010 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
1011 $result = $wpdb->insert(
1012 self::get_funnel_table(),
1013 array(
1014 'postnumber' => absint( $post_id ),
1015 'blog_id' => absint( $blog_id ),
1016 'visited_at' => current_time( 'mysql' ),
1017 'activate_counter' => (int) $activate_counter,
1018 'source' => (int) $source,
1019 ),
1020 array( '%d', '%d', '%s', '%d', '%d' )
1021 );
1022
1023 if ( false === $result ) {
1024 self::clear_table_installation_cache();
1025 }
1026
1027 return $result;
1028 }
1029
1030 /**
1031 * Drain the funnel into the log and count tables, then empty the funnel.
1032 *
1033 * Each of the four steps (copy to log, aggregate to daily, aggregate to overall,
1034 * delete from funnel) commits independently rather than inside one app-level
1035 * transaction. wpdb silently reconnects and retries a query if the DB connection
1036 * drops mid-request, which would otherwise void an in-flight transaction and let
1037 * later steps (e.g. the funnel delete) commit on a fresh connection while earlier
1038 * ones were rolled back — losing visits with no error. Without a wrapping
1039 * transaction, a crash between steps can at worst cause one batch to be
1040 * re-aggregated (a bounded, self-correcting over-count), never a silent loss.
1041 *
1042 * @since 4.3.0
1043 *
1044 * @param int $batch_size Maximum funnel rows to process per run.
1045 * @param int|null $blog_id Optional blog ID. When set, only that site's buffered visits are processed.
1046 * @return true|false|int|\WP_Error True if rows processed, false if lock not acquired, 0 if funnel empty, WP_Error on DB failure.
1047 */
1048 public static function aggregate_visit_log( $batch_size = 10000, $blog_id = null ) {
1049 global $wpdb;
1050
1051 $batch_size = max( 1, absint( $batch_size ) );
1052 $blog_id = null === $blog_id ? null : absint( $blog_id );
1053 $blog_where = null === $blog_id ? '' : $wpdb->prepare( ' AND blog_id = %d', $blog_id );
1054
1055 // Detect SQLite (e.g. WordPress Playground) vs MySQL/MariaDB.
1056 // DATABASE_TYPE is defined by the WordPress SQLite Database Integration drop-in.
1057 $is_sqlite = ( defined( 'DATABASE_TYPE' ) && 'sqlite' === DATABASE_TYPE )
1058 || false !== strpos( strtolower( (string) $wpdb->db_server_info() ), 'sqlite' );
1059
1060 $funnel_table = self::get_funnel_table();
1061 $log_table = self::get_log_table();
1062 $daily_table = self::get_table( true );
1063 $full_table = self::get_table( false );
1064
1065 // GET_LOCK is a MySQL-only concurrency guard against overlapping cron runs; not needed for correctness.
1066 if ( ! $is_sqlite ) {
1067 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1068 $lock_acquired = $wpdb->get_var( "SELECT GET_LOCK('tptn_aggregation', 0)" );
1069 if ( '1' !== (string) $lock_acquired ) {
1070 return false;
1071 }
1072 }
1073
1074 try {
1075 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1076 $max_id = (int) $wpdb->get_var( "SELECT MAX(id) FROM {$funnel_table} WHERE 1=1{$blog_where}" );
1077 if ( 0 === $max_id ) {
1078 return 0;
1079 }
1080
1081 $cap_id = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$funnel_table} WHERE 1=1{$blog_where} ORDER BY id ASC LIMIT %d, 1", $batch_size ) );
1082 $was_capped = false;
1083 if ( null !== $cap_id ) {
1084 $capped_max = (int) $cap_id - 1;
1085 if ( $capped_max > 0 ) {
1086 $max_id = $capped_max;
1087 $was_capped = true;
1088 }
1089 }
1090
1091 $r = $wpdb->query(
1092 $wpdb->prepare(
1093 "INSERT INTO {$log_table} (postnumber, blog_id, visited_at, source)
1094 SELECT postnumber, blog_id, visited_at, source
1095 FROM {$funnel_table}
1096 WHERE id <= %d{$blog_where}",
1097 $max_id
1098 )
1099 );
1100 if ( false === $r ) {
1101 return new \WP_Error( 'tptn_log_insert_failed', $wpdb->last_error ? $wpdb->last_error : __( 'Failed to copy visits to log table.', 'top-10' ) );
1102 }
1103
1104 $r = $wpdb->query(
1105 $wpdb->prepare(
1106 "INSERT INTO {$daily_table} (postnumber, cntaccess, dp_date, blog_id)
1107 SELECT postnumber, COUNT(*) AS cntaccess,
1108 DATE_FORMAT(visited_at, '%%Y-%%m-%%d %%H:00:00') AS dp_date, blog_id
1109 FROM {$funnel_table}
1110 WHERE id <= %d AND activate_counter IN (10, 11){$blog_where}
1111 GROUP BY postnumber, DATE_FORMAT(visited_at, '%%Y-%%m-%%d %%H:00:00'), blog_id
1112 ON DUPLICATE KEY UPDATE cntaccess = {$daily_table}.cntaccess + VALUES(cntaccess)",
1113 $max_id
1114 )
1115 );
1116 if ( false === $r ) {
1117 return new \WP_Error( 'tptn_daily_insert_failed', $wpdb->last_error ? $wpdb->last_error : __( 'Failed to aggregate visits into daily table.', 'top-10' ) );
1118 }
1119
1120 $r = $wpdb->query(
1121 $wpdb->prepare(
1122 "INSERT INTO {$full_table} (postnumber, cntaccess, blog_id)
1123 SELECT postnumber, COUNT(*) AS cntaccess, blog_id
1124 FROM {$funnel_table}
1125 WHERE id <= %d AND activate_counter IN (1, 11){$blog_where}
1126 GROUP BY postnumber, blog_id
1127 ON DUPLICATE KEY UPDATE cntaccess = {$full_table}.cntaccess + VALUES(cntaccess)",
1128 $max_id
1129 )
1130 );
1131 if ( false === $r ) {
1132 return new \WP_Error( 'tptn_overall_insert_failed', $wpdb->last_error ? $wpdb->last_error : __( 'Failed to aggregate visits into overall table.', 'top-10' ) );
1133 }
1134
1135 $r = $wpdb->query( $wpdb->prepare( "DELETE FROM {$funnel_table} WHERE id <= %d{$blog_where}", $max_id ) );
1136 if ( false === $r ) {
1137 return new \WP_Error( 'tptn_funnel_delete_failed', $wpdb->last_error ? $wpdb->last_error : __( 'Failed to drain funnel table.', 'top-10' ) );
1138 }
1139 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1140
1141 /**
1142 * Fires after view counts change, so caches can be invalidated.
1143 *
1144 * Bulk operations such as funnel aggregation and the daily rollup pass zeros,
1145 * signalling that many posts changed rather than one specific post.
1146 *
1147 * @since 4.2.0
1148 *
1149 * @param int $post_id Post ID, or 0 after a bulk update.
1150 * @param int $blog_id Blog ID, or 0 after a bulk update.
1151 * @param bool $daily Whether the daily table was updated.
1152 */
1153 do_action( 'tptn_count_updated', 0, 0, false );
1154
1155 if ( $was_capped && ! wp_next_scheduled( 'tptn_aggregation_cron_hook' ) ) {
1156 wp_schedule_single_event( time(), 'tptn_aggregation_cron_hook' );
1157 }
1158
1159 return true;
1160 } finally {
1161 if ( ! $is_sqlite ) {
1162 $wpdb->query( "SELECT RELEASE_LOCK('tptn_aggregation')" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1163 }
1164 }
1165 }
1166
1167 /**
1168 * Recreate a table.
1169 *
1170 * This method recreates a table by creating a backup, dropping the original table,
1171 * and then creating a new table with the original name and inserting the data from the backup.
1172 *
1173 * @since 4.2.0
1174 *
1175 * @param string $table_name The name of the table to recreate.
1176 * @param string $create_table_sql The SQL statement to create the new table.
1177 * @param bool $backup Whether to backup the table or not.
1178 * @param array $fields The fields to include in the temporary table and on duplicate key code.
1179 * @param array $group_by_fields The fields to group by in the temporary table.
1180 *
1181 * @return bool|\WP_Error True if recreated, error message if failed.
1182 */
1183 public static function recreate_table(
1184 $table_name,
1185 $create_table_sql,
1186 $backup = true,
1187 $fields = array( 'postnumber', 'cntaccess', 'blog_id' ),
1188 $group_by_fields = array( 'postnumber', 'blog_id' )
1189 ) {
1190 global $wpdb;
1191
1192 $backup_table_name = ( $backup ) ? $table_name . '_backup' : $table_name . '_temp';
1193 $success = false;
1194
1195 $fields_sql = implode( ', ', $fields );
1196 $fields_sql_with_sum = str_replace( 'cntaccess', 'SUM(cntaccess) as cntaccess', $fields_sql );
1197 $group_by_sql = implode( ', ', $group_by_fields );
1198
1199 if ( $backup ) {
1200 $success = $wpdb->query( "CREATE TABLE $backup_table_name LIKE $table_name" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1201 if ( false !== $success ) {
1202 $success = $wpdb->query( "INSERT INTO $backup_table_name SELECT * FROM $table_name" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1203 } else {
1204 /* translators: 1: Site number, 2: Error message */
1205 return new \WP_Error( 'tptn_database_backup_failed', sprintf( esc_html__( 'Database backup failed on site %1$s. Error message: %2$s', 'top-10' ), get_site_url(), $wpdb->last_error ) );
1206 }
1207 } else {
1208 $wpdb->query( "DROP TEMPORARY TABLE IF EXISTS $backup_table_name" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1209 $success = $wpdb->query( "CREATE TEMPORARY TABLE $backup_table_name AS SELECT $fields_sql_with_sum FROM $table_name GROUP BY $group_by_sql" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1210 }
1211
1212 if ( false !== $success ) {
1213 $wpdb->query( "DROP TABLE IF EXISTS $table_name" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1214
1215 // Direct table creation without dbDelta for recreation.
1216 $wpdb->query( $create_table_sql ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
1217
1218 $insert_fields_sql = 'tt.' . implode( ', tt.', $fields );
1219
1220 $success = $wpdb->query( "INSERT INTO $table_name ($fields_sql) SELECT $insert_fields_sql FROM $backup_table_name AS tt ON DUPLICATE KEY UPDATE $table_name.cntaccess = $table_name.cntaccess + VALUES(cntaccess)" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1221
1222 if ( false === $success ) {
1223 /* translators: 1: Site number, 2: Error message */
1224 return new \WP_Error( 'tptn_database_insert_failed', sprintf( esc_html__( 'Database insert failed on site %1$s. Error message: %2$s', 'top-10' ), get_site_url(), $wpdb->last_error ) );
1225 }
1226 }
1227
1228 if ( ! $backup ) {
1229 $wpdb->query( "DROP TEMPORARY TABLE IF EXISTS $backup_table_name" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1230 }
1231
1232 return $success;
1233 }
1234
1235 /**
1236 * Recreate overall table.
1237 *
1238 * @since 4.2.0
1239 *
1240 * @param bool $backup Whether to backup the table or not.
1241 *
1242 * @return bool|\WP_Error True if recreated, error message if failed.
1243 */
1244 public static function recreate_overall_table( $backup = true ) {
1245 global $wpdb;
1246 return self::recreate_table(
1247 $wpdb->base_prefix . 'top_ten',
1248 self::create_full_table_sql(),
1249 $backup
1250 );
1251 }
1252
1253 /**
1254 * Recreate daily table.
1255 *
1256 * @since 4.2.0
1257 *
1258 * @param bool $backup Whether to backup the table or not.
1259 *
1260 * @return bool|\WP_Error True if recreated, error message if failed.
1261 */
1262 public static function recreate_daily_table( $backup = true ) {
1263 global $wpdb;
1264 return self::recreate_table(
1265 $wpdb->base_prefix . 'top_ten_daily',
1266 self::create_daily_table_sql(),
1267 $backup,
1268 array( 'postnumber', 'cntaccess', 'dp_date', 'blog_id' ),
1269 array( 'postnumber', 'dp_date', 'blog_id' )
1270 );
1271 }
1272
1273 /**
1274 * Recreate visits funnel table.
1275 *
1276 * @since 4.3.0
1277 *
1278 * @param bool $backup Whether to create a permanent backup table before recreating.
1279 *
1280 * @return bool|\WP_Error True if recreated, error message if failed.
1281 */
1282 public static function recreate_funnel_table( $backup = true ) {
1283 global $wpdb;
1284
1285 $table_name = self::get_funnel_table();
1286 $backup_table_name = $backup ? $table_name . '_backup' : $table_name . '_temp';
1287 $fields_sql = 'postnumber, blog_id, visited_at, activate_counter, source';
1288 $success = false;
1289
1290 if ( $backup ) {
1291 $success = $wpdb->query( "CREATE TABLE $backup_table_name LIKE $table_name" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1292 if ( false === $success ) {
1293 /* translators: 1: Site number, 2: Error message */
1294 return new \WP_Error( 'tptn_database_backup_failed', sprintf( esc_html__( 'Database backup failed on site %1$s. Error message: %2$s', 'top-10' ), get_site_url(), $wpdb->last_error ) );
1295 }
1296 $wpdb->query( "INSERT INTO $backup_table_name SELECT * FROM $table_name" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1297 } else {
1298 $wpdb->query( "DROP TEMPORARY TABLE IF EXISTS $backup_table_name" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1299 $success = $wpdb->query( "CREATE TEMPORARY TABLE $backup_table_name AS SELECT $fields_sql FROM $table_name" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1300 }
1301
1302 if ( false !== $success ) {
1303 $wpdb->query( "DROP TABLE IF EXISTS $table_name" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1304 $wpdb->query( self::create_funnel_table_sql() ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
1305
1306 $success = $wpdb->query( "INSERT INTO $table_name ($fields_sql) SELECT $fields_sql FROM $backup_table_name" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1307
1308 if ( false === $success ) {
1309 /* translators: 1: Site number, 2: Error message */
1310 return new \WP_Error( 'tptn_database_insert_failed', sprintf( esc_html__( 'Database insert failed on site %1$s. Error message: %2$s', 'top-10' ), get_site_url(), $wpdb->last_error ) );
1311 }
1312 }
1313
1314 if ( ! $backup ) {
1315 $wpdb->query( "DROP TEMPORARY TABLE IF EXISTS $backup_table_name" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1316 }
1317
1318 return $success;
1319 }
1320
1321 /**
1322 * Recreate visits log table.
1323 *
1324 * @since 4.3.0
1325 *
1326 * @param bool $backup Whether to create a permanent backup table before recreating.
1327 *
1328 * @return bool|\WP_Error True if recreated, error message if failed.
1329 */
1330 public static function recreate_log_table( $backup = true ) {
1331 global $wpdb;
1332
1333 $table_name = self::get_log_table();
1334 $backup_table_name = $backup ? $table_name . '_backup' : $table_name . '_temp';
1335 $fields_sql = 'postnumber, blog_id, visited_at, source';
1336 $success = false;
1337
1338 if ( $backup ) {
1339 $success = $wpdb->query( "CREATE TABLE $backup_table_name LIKE $table_name" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1340 if ( false === $success ) {
1341 /* translators: 1: Site number, 2: Error message */
1342 return new \WP_Error( 'tptn_database_backup_failed', sprintf( esc_html__( 'Database backup failed on site %1$s. Error message: %2$s', 'top-10' ), get_site_url(), $wpdb->last_error ) );
1343 }
1344 $wpdb->query( "INSERT INTO $backup_table_name SELECT * FROM $table_name" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1345 } else {
1346 $wpdb->query( "DROP TEMPORARY TABLE IF EXISTS $backup_table_name" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1347 $success = $wpdb->query( "CREATE TEMPORARY TABLE $backup_table_name AS SELECT $fields_sql FROM $table_name" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1348 }
1349
1350 if ( false !== $success ) {
1351 $wpdb->query( "DROP TABLE IF EXISTS $table_name" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1352 $wpdb->query( self::create_log_table_sql() ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
1353
1354 $success = $wpdb->query( "INSERT INTO $table_name ($fields_sql) SELECT $fields_sql FROM $backup_table_name" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1355
1356 if ( false === $success ) {
1357 /* translators: 1: Site number, 2: Error message */
1358 return new \WP_Error( 'tptn_database_insert_failed', sprintf( esc_html__( 'Database insert failed on site %1$s. Error message: %2$s', 'top-10' ), get_site_url(), $wpdb->last_error ) );
1359 }
1360 }
1361
1362 if ( ! $backup ) {
1363 $wpdb->query( "DROP TEMPORARY TABLE IF EXISTS $backup_table_name" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1364 }
1365
1366 return $success;
1367 }
1368
1369 /**
1370 * Truncate a table.
1371 *
1372 * @since 4.2.0
1373 *
1374 * @param string $table_name Table name to truncate.
1375 * @return bool True on success, false on failure.
1376 */
1377 public static function truncate_table( $table_name ) {
1378 global $wpdb;
1379
1380 // Table names cannot be parameterized in TRUNCATE statements.
1381 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1382 return $wpdb->query( "TRUNCATE TABLE $table_name" );
1383 }
1384
1385 /**
1386 * Count rows in the daily table that would be pruned up to a given date.
1387 *
1388 * @since 4.3.0
1389 *
1390 * @param string $to_date Rows with dp_date at or before this value are counted.
1391 * @return int Row count.
1392 */
1393 public static function count_deletable_daily_rows( string $to_date ): int {
1394 global $wpdb;
1395 $table = self::get_table( true );
1396 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1397 return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE dp_date <= %s", $to_date ) );
1398 }
1399
1400 /**
1401 * Get the daily-table row counts before and after a rollup.
1402 *
1403 * The projected row count groups rows by post, blog, and calendar date.
1404 *
1405 * @since 4.5.0
1406 *
1407 * @param string $before_date Rows before this date are included.
1408 * @param int|null $blog_id Blog ID. Defaults to the current blog.
1409 * @return array|\WP_Error Rollup statistics or an error.
1410 */
1411 public static function get_daily_rollup_stats( string $before_date, $blog_id = null ) {
1412 global $wpdb;
1413
1414 $before_date = self::normalize_daily_rollup_date( $before_date );
1415 if ( is_wp_error( $before_date ) ) {
1416 return $before_date;
1417 }
1418
1419 $blog_id = null === $blog_id ? get_current_blog_id() : absint( $blog_id );
1420 $table = self::get_table( true );
1421
1422 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1423 $rows_before = $wpdb->get_var(
1424 $wpdb->prepare(
1425 "SELECT COUNT(*) FROM `{$table}` WHERE blog_id = %d AND dp_date < %s",
1426 $blog_id,
1427 $before_date
1428 )
1429 );
1430 if ( null === $rows_before ) {
1431 return new \WP_Error( 'tptn_rollup_count_failed', $wpdb->last_error ? $wpdb->last_error : __( 'Could not count daily rows before the rollup.', 'top-10' ) );
1432 }
1433
1434 $rows_after = $wpdb->get_var(
1435 $wpdb->prepare(
1436 "SELECT COUNT(*) FROM (
1437 SELECT postnumber, blog_id, DATE(dp_date) AS rollup_date
1438 FROM `{$table}`
1439 WHERE blog_id = %d AND dp_date < %s
1440 GROUP BY postnumber, blog_id, DATE(dp_date)
1441 ) AS rollup_groups",
1442 $blog_id,
1443 $before_date
1444 )
1445 );
1446 if ( null === $rows_after ) {
1447 return new \WP_Error( 'tptn_rollup_projection_failed', $wpdb->last_error ? $wpdb->last_error : __( 'Could not calculate the projected daily row count.', 'top-10' ) );
1448 }
1449
1450 $dates = $wpdb->get_var(
1451 $wpdb->prepare(
1452 "SELECT COUNT(DISTINCT DATE(dp_date)) FROM `{$table}` WHERE blog_id = %d AND dp_date < %s",
1453 $blog_id,
1454 $before_date
1455 )
1456 );
1457 if ( null === $dates ) {
1458 return new \WP_Error( 'tptn_rollup_dates_failed', $wpdb->last_error ? $wpdb->last_error : __( 'Could not count daily rollup dates.', 'top-10' ) );
1459 }
1460 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1461
1462 return array(
1463 'rows_before' => (int) $rows_before,
1464 'rows_after' => (int) $rows_after,
1465 'rows_reduced' => max( 0, (int) $rows_before - (int) $rows_after ),
1466 'dates' => (int) $dates,
1467 );
1468 }
1469
1470 /**
1471 * Roll up hourly daily rows older than a date into one midnight row per post.
1472 *
1473 * Each calendar date is processed in its own transaction so an interrupted
1474 * operation can safely resume on the next date. The overall count table is
1475 * never modified.
1476 *
1477 * @since 4.5.0
1478 *
1479 * @param string $before_date Rows before this date are rolled up.
1480 * @param int|null $blog_id Blog ID. Defaults to the current blog.
1481 * @return array|\WP_Error Rollup statistics or an error.
1482 */
1483 public static function rollup_daily( string $before_date, $blog_id = null ) {
1484 global $wpdb;
1485
1486 $before_date = self::normalize_daily_rollup_date( $before_date );
1487 if ( is_wp_error( $before_date ) ) {
1488 return $before_date;
1489 }
1490
1491 $blog_id = null === $blog_id ? get_current_blog_id() : absint( $blog_id );
1492 $table = self::get_table( true );
1493 $before = self::get_daily_rollup_stats( $before_date, $blog_id );
1494 if ( is_wp_error( $before ) ) {
1495 return $before;
1496 }
1497
1498 $last_date = '';
1499 $dates_processed = 0;
1500
1501 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1502 while ( true ) {
1503 // Select one unprocessed date at a time. Existing midnight rows are
1504 // already rolled up and are therefore skipped on subsequent runs.
1505 if ( '' === $last_date ) {
1506 $next_date = $wpdb->get_var(
1507 $wpdb->prepare(
1508 "SELECT DATE(dp_date) FROM `{$table}` WHERE blog_id = %d AND dp_date < %s AND TIME(dp_date) <> '00:00:00' ORDER BY dp_date ASC LIMIT 1",
1509 $blog_id,
1510 $before_date
1511 )
1512 );
1513 } else {
1514 $next_date_start = ( new \DateTimeImmutable( $last_date, new \DateTimeZone( 'UTC' ) ) )->modify( '+1 day' )->format( 'Y-m-d 00:00:00' );
1515 $next_date = $wpdb->get_var(
1516 $wpdb->prepare(
1517 "SELECT DATE(dp_date) FROM `{$table}` WHERE blog_id = %d AND dp_date >= %s AND dp_date < %s AND TIME(dp_date) <> '00:00:00' ORDER BY dp_date ASC LIMIT 1",
1518 $blog_id,
1519 $next_date_start,
1520 $before_date
1521 )
1522 );
1523 }
1524
1525 if ( null === $next_date ) {
1526 if ( ! empty( $wpdb->last_error ) ) {
1527 return new \WP_Error( 'tptn_rollup_date_failed', $wpdb->last_error );
1528 }
1529 break;
1530 }
1531
1532 $day_start = $next_date . ' 00:00:00';
1533 $day_end = ( new \DateTimeImmutable( $next_date, new \DateTimeZone( 'UTC' ) ) )->modify( '+1 day' )->format( 'Y-m-d 00:00:00' );
1534 $transaction_open = false;
1535
1536 if ( false === $wpdb->query( 'START TRANSACTION' ) ) {
1537 return new \WP_Error( 'tptn_rollup_start_failed', $wpdb->last_error ? $wpdb->last_error : __( 'Could not start the daily rollup transaction.', 'top-10' ) );
1538 }
1539 $transaction_open = true;
1540
1541 try {
1542 $daily_rows = $wpdb->get_results(
1543 $wpdb->prepare(
1544 "SELECT postnumber, cntaccess
1545 FROM `{$table}`
1546 WHERE blog_id = %d AND dp_date >= %s AND dp_date < %s
1547 ORDER BY postnumber ASC
1548 FOR UPDATE",
1549 $blog_id,
1550 $day_start,
1551 $day_end
1552 ),
1553 ARRAY_A
1554 );
1555 if ( null === $daily_rows ) {
1556 return new \WP_Error( 'tptn_rollup_select_failed', $wpdb->last_error ? $wpdb->last_error : __( 'Could not read the daily rows for the rollup.', 'top-10' ) );
1557 }
1558
1559 $rollup_counts = array();
1560 foreach ( $daily_rows as $daily_row ) {
1561 $postnumber = (int) $daily_row['postnumber'];
1562 if ( ! isset( $rollup_counts[ $postnumber ] ) ) {
1563 $rollup_counts[ $postnumber ] = 0;
1564 }
1565 $rollup_counts[ $postnumber ] += (int) $daily_row['cntaccess'];
1566 }
1567
1568 foreach ( array_chunk( $rollup_counts, 500, true ) as $rollup_batch ) {
1569 $values = array();
1570 foreach ( $rollup_batch as $postnumber => $count ) {
1571 $values[] = $wpdb->prepare(
1572 '( %d, %d, %s, %d )',
1573 $postnumber,
1574 $count,
1575 $day_start,
1576 $blog_id
1577 );
1578 }
1579
1580 $result = $wpdb->query(
1581 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1582 "INSERT INTO `{$table}` (postnumber, cntaccess, dp_date, blog_id) VALUES " . implode( ',', $values ) . ' ON DUPLICATE KEY UPDATE cntaccess = VALUES(cntaccess)'
1583 );
1584 if ( false === $result ) {
1585 return new \WP_Error( 'tptn_rollup_insert_failed', $wpdb->last_error ? $wpdb->last_error : __( 'Could not write the daily rollup.', 'top-10' ) );
1586 }
1587 }
1588
1589 $result = $wpdb->query(
1590 $wpdb->prepare(
1591 "DELETE FROM `{$table}` WHERE blog_id = %d AND dp_date >= %s AND dp_date < %s AND dp_date <> %s",
1592 $blog_id,
1593 $day_start,
1594 $day_end,
1595 $day_start
1596 )
1597 );
1598 if ( false === $result ) {
1599 return new \WP_Error( 'tptn_rollup_delete_failed', $wpdb->last_error ? $wpdb->last_error : __( 'Could not remove the hourly daily rows.', 'top-10' ) );
1600 }
1601
1602 if ( false === $wpdb->query( 'COMMIT' ) ) {
1603 return new \WP_Error( 'tptn_rollup_commit_failed', $wpdb->last_error ? $wpdb->last_error : __( 'Could not commit the daily rollup.', 'top-10' ) );
1604 }
1605 $transaction_open = false;
1606 } finally {
1607 if ( $transaction_open ) {
1608 $wpdb->query( 'ROLLBACK' );
1609 }
1610 }
1611 ++$dates_processed;
1612 $last_date = $next_date;
1613 }
1614 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1615
1616 $after = self::get_daily_rollup_stats( $before_date, $blog_id );
1617 if ( is_wp_error( $after ) ) {
1618 return $after;
1619 }
1620
1621 if ( $dates_processed > 0 ) {
1622 /** This action is documented in includes/class-database.php */
1623 do_action( 'tptn_count_updated', 0, 0, false );
1624 }
1625
1626 return array(
1627 'rows_before' => $before['rows_before'],
1628 'rows_after' => $after['rows_before'],
1629 'rows_reduced' => max( 0, $before['rows_before'] - $after['rows_before'] ),
1630 'dates' => $after['dates'],
1631 'dates_processed' => $dates_processed,
1632 );
1633 }
1634
1635 /**
1636 * Normalize a rollup boundary to midnight.
1637 *
1638 * @since 4.5.0
1639 *
1640 * @param string $before_date Rollup boundary in Y-m-d or Y-m-d 00:00:00 format.
1641 * @return string|\WP_Error Normalized date or an error.
1642 */
1643 private static function normalize_daily_rollup_date( string $before_date ) {
1644 $before_date = trim( $before_date, " \t\n\r\0\x0B" );
1645 $date = preg_replace( '/ 00:00:00$/', '', $before_date );
1646
1647 if ( ! is_string( $date ) || ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) {
1648 return new \WP_Error( 'tptn_invalid_rollup_date', __( 'The daily rollup boundary must be a valid date in Y-m-d format.', 'top-10' ) );
1649 }
1650
1651 $date_object = \DateTimeImmutable::createFromFormat( '!Y-m-d', $date, new \DateTimeZone( 'UTC' ) );
1652 $errors = \DateTimeImmutable::getLastErrors();
1653 if ( false === $date_object || ( is_array( $errors ) && ( $errors['warning_count'] > 0 || $errors['error_count'] > 0 ) ) ) {
1654 return new \WP_Error( 'tptn_invalid_rollup_date', __( 'The daily rollup boundary must be a valid date in Y-m-d format.', 'top-10' ) );
1655 }
1656
1657 return $date_object->format( 'Y-m-d 00:00:00' );
1658 }
1659
1660 /**
1661 * Count rows in the visits log table older than a given datetime.
1662 *
1663 * @since 4.3.0
1664 *
1665 * @param string $before_datetime Rows with visited_at before this value are counted.
1666 * @return int Row count.
1667 */
1668 public static function count_deletable_log_rows( string $before_datetime ): int {
1669 global $wpdb;
1670 $table = self::get_log_table();
1671 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1672 return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE visited_at < %s", $before_datetime ) );
1673 }
1674
1675 /**
1676 * Delete rows from the visits log table older than a given datetime.
1677 *
1678 * @since 4.3.0
1679 *
1680 * @param string $before_datetime Rows with visited_at before this value are deleted.
1681 * @param int $batch_size Maximum rows to delete per call.
1682 * @return int|false Rows deleted, or false on failure.
1683 */
1684 public static function prune_log_table( string $before_datetime, int $batch_size = 1000 ) {
1685 global $wpdb;
1686 $table = self::get_log_table();
1687 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1688 return $wpdb->query( $wpdb->prepare( "DELETE FROM `{$table}` WHERE visited_at < %s LIMIT %d", $before_datetime, $batch_size ) );
1689 }
1690
1691 /**
1692 * Count rows in the visits funnel table.
1693 *
1694 * @since 4.3.0
1695 *
1696 * @return int Row count.
1697 */
1698 public static function count_funnel_rows(): int {
1699 global $wpdb;
1700 $table = self::get_funnel_table();
1701 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1702 return (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
1703 }
1704
1705 /**
1706 * Count orphaned rows in a count table (rows with no matching post).
1707 *
1708 * Only inspects rows belonging to the current blog so that posts on other
1709 * sites in a multisite network are not falsely reported as orphans.
1710 *
1711 * @since 4.3.0
1712 *
1713 * @param string $table_name Count table to inspect.
1714 * @return int Row count.
1715 */
1716 public static function count_orphan_counts( string $table_name ): int {
1717 global $wpdb;
1718 $blog_id = get_current_blog_id();
1719 /**
1720 * Filters the reserved post IDs used to store site-wide view counts.
1721 *
1722 * @since 4.5.0
1723 *
1724 * @param int[] $context_ids Reserved context IDs. Default empty array.
1725 */
1726 $context_ids = array_map( 'intval', (array) apply_filters( 'tptn_sitewide_context_ids', array() ) );
1727 $context_where = '';
1728 if ( $context_ids ) {
1729 $context_where = ' AND t.postnumber NOT IN (' . implode( ',', $context_ids ) . ')';
1730 }
1731 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1732 return (int) $wpdb->get_var(
1733 $wpdb->prepare(
1734 "SELECT COUNT(*) FROM `{$table_name}` t
1735 LEFT JOIN `{$wpdb->posts}` p ON t.postnumber = p.ID
1736 WHERE p.ID IS NULL AND t.blog_id = %d{$context_where}",
1737 $blog_id
1738 )
1739 );
1740 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1741 }
1742
1743 /**
1744 * Delete orphaned rows from a count table (rows with no matching post).
1745 *
1746 * Only deletes rows belonging to the current blog so that posts on other
1747 * sites in a multisite network are not falsely treated as orphans.
1748 *
1749 * @since 4.3.0
1750 *
1751 * @param string $table_name Count table to clean.
1752 * @param int $batch_size Maximum rows to delete per call.
1753 * @return int|false Rows deleted, or false on failure.
1754 */
1755 public static function delete_orphan_counts( string $table_name, int $batch_size = 1000 ) {
1756 global $wpdb;
1757 $blog_id = get_current_blog_id();
1758 /** This filter is documented in includes/class-database.php */
1759 $context_ids = array_map( 'intval', (array) apply_filters( 'tptn_sitewide_context_ids', array() ) );
1760 $context_where = '';
1761 if ( $context_ids ) {
1762 $context_where = ' AND t.postnumber NOT IN (' . implode( ',', $context_ids ) . ')';
1763 }
1764 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1765 return $wpdb->query(
1766 $wpdb->prepare(
1767 "DELETE t FROM `{$table_name}` t
1768 LEFT JOIN `{$wpdb->posts}` p ON t.postnumber = p.ID
1769 WHERE p.ID IS NULL AND t.blog_id = %d{$context_where}
1770 LIMIT %d",
1771 $blog_id,
1772 $batch_size
1773 )
1774 );
1775 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1776 }
1777 }
1778