PluginProbe
WebberZone Top 10 — Popular Posts / 4.3.0
WebberZone Top 10 — Popular Posts v4.3.0
4.5.2 4.5.1 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 All 118 releases
top-10 / includes / class-database.php

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

1,072 lines 36.3 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 * Constructor.
21 */
22 public function __construct() {
23 // No initialization needed for static methods.
24 }
25
26 /**
27 * Get the table name for overall or daily counts.
28 *
29 * @since 4.2.0
30 *
31 * @param bool $daily Whether to get the daily table.
32 * @return string Table name.
33 */
34 public static function get_table( $daily = false ) {
35 global $wpdb;
36
37 $table_name = $wpdb->base_prefix . 'top_ten';
38 if ( $daily ) {
39 $table_name .= '_daily';
40 }
41 return $table_name;
42 }
43
44 /**
45 * Get count for a specific post.
46 *
47 * @since 4.2.0
48 *
49 * @param int $post_id Post ID.
50 * @param int $blog_id Blog ID (optional, defaults to current blog).
51 * @param bool $daily Whether to get daily count.
52 * @param array $date_range Date range array for daily counts ['from_date', 'to_date'].
53 * @return int Post count.
54 */
55 public static function get_count( $post_id, $blog_id = null, $daily = false, $date_range = array() ) {
56 global $wpdb;
57
58 $blog_id = $blog_id ?? get_current_blog_id();
59 $table = self::get_table( $daily );
60
61 if ( $daily && ! empty( $date_range ) ) {
62 $where = $wpdb->prepare( 'WHERE postnumber = %d AND blog_id = %d', $post_id, $blog_id );
63
64 if ( ! empty( $date_range['from_date'] ) ) {
65 $where .= $wpdb->prepare( ' AND dp_date >= %s', $date_range['from_date'] );
66 }
67 if ( ! empty( $date_range['to_date'] ) ) {
68 $where .= $wpdb->prepare( ' AND dp_date <= %s', $date_range['to_date'] );
69 }
70
71 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
72 $sql = "SELECT SUM(cntaccess) FROM {$table} {$where}";
73 } else {
74 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
75 $sql = $wpdb->prepare( "SELECT cntaccess FROM {$table} WHERE postnumber = %d AND blog_id = %d", $post_id, $blog_id );
76 }
77
78 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
79 return (int) $wpdb->get_var( $sql );
80 }
81
82 /**
83 * Update count for a post.
84 *
85 * @since 4.2.0
86 * @deprecated 4.3.0 Use {@see Database::append_to_funnel()} instead.
87 *
88 * @param int $post_id Post ID.
89 * @param int $blog_id Blog ID (optional, defaults to current blog).
90 * @param bool $daily Whether to update daily count.
91 * @return int|false Number of rows affected or false on error.
92 */
93 public static function update_count( $post_id, $blog_id = null, $daily = false ) {
94 _deprecated_function( __METHOD__, '4.3.0', 'Database::append_to_funnel()' );
95
96 $blog_id = $blog_id ?? get_current_blog_id();
97 $activate_counter = $daily ? 10 : 1;
98
99 return self::append_to_funnel( $post_id, $blog_id, $activate_counter );
100 }
101
102 /**
103 * Set count for a post to a specific value.
104 *
105 * @since 4.2.0
106 *
107 * @param int $post_id Post ID.
108 * @param int $count Count value to set.
109 * @param int $blog_id Blog ID (optional, defaults to current blog).
110 * @param bool $daily Whether to update daily count.
111 * @return int|false Number of rows affected or false on error.
112 */
113 public static function set_count( $post_id, $count, $blog_id = null, $daily = false ) {
114 global $wpdb;
115
116 $blog_id = $blog_id ?? get_current_blog_id();
117 $table = self::get_table( $daily );
118
119 if ( $daily ) {
120 $dp_date = current_time( 'Y-m-d H' );
121 $sql = $wpdb->prepare(
122 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
123 "INSERT INTO {$table} (postnumber, cntaccess, dp_date, blog_id) VALUES (%d, %d, %s, %d) ON DUPLICATE KEY UPDATE cntaccess = %d",
124 $post_id,
125 $count,
126 $dp_date,
127 $blog_id,
128 $count
129 );
130 } else {
131 $sql = $wpdb->prepare(
132 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
133 "INSERT INTO {$table} (postnumber, cntaccess, blog_id) VALUES (%d, %d, %d) ON DUPLICATE KEY UPDATE cntaccess = %d",
134 $post_id,
135 $count,
136 $blog_id,
137 $count
138 );
139 }
140
141 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
142 $result = $wpdb->query( $sql );
143
144 // Trigger action to clear cache.
145 if ( false !== $result ) {
146 do_action( 'tptn_set_count', $post_id, $count, $blog_id, $daily );
147 }
148
149 return $result;
150 }
151
152 /**
153 * Delete counts based on criteria.
154 *
155 * @since 4.2.0
156 *
157 * @param array $args {
158 * Optional. Array of arguments.
159 *
160 * @type array $post_ids Array of post IDs to delete.
161 * @type int $blog_id Blog ID to delete from.
162 * @type string $from_date Delete entries from this date (daily table only).
163 * @type string $to_date Delete entries until this date (daily table only).
164 * @type bool $daily Whether to delete from daily table.
165 * @type int $limit Maximum number of rows to delete per call (0 = no limit).
166 * }
167 * @return int|false Number of rows deleted or false on error.
168 */
169 public static function delete_counts( $args = array() ) {
170 global $wpdb;
171
172 $defaults = array(
173 'post_ids' => array(),
174 'blog_id' => null,
175 'from_date' => '',
176 'to_date' => '',
177 'daily' => false,
178 'limit' => 0,
179 );
180 $args = wp_parse_args( $args, $defaults );
181
182 $table = self::get_table( $args['daily'] );
183 $where = array();
184
185 if ( ! empty( $args['post_ids'] ) ) {
186 $post_ids = array_map( 'intval', $args['post_ids'] );
187 $where[] = 'postnumber IN (' . implode( ',', $post_ids ) . ')';
188 }
189
190 if ( null !== $args['blog_id'] ) {
191 $where[] = $wpdb->prepare( 'blog_id = %d', $args['blog_id'] );
192 }
193
194 if ( $args['daily'] ) {
195 if ( ! empty( $args['from_date'] ) ) {
196 $where[] = $wpdb->prepare( 'dp_date >= %s', $args['from_date'] );
197 }
198 if ( ! empty( $args['to_date'] ) ) {
199 $where[] = $wpdb->prepare( 'dp_date <= %s', $args['to_date'] );
200 }
201 }
202
203 $sql = "DELETE FROM {$table}";
204 if ( ! empty( $where ) ) {
205 $sql .= ' WHERE ' . implode( ' AND ', $where );
206 }
207
208 if ( ! empty( $args['limit'] ) && $args['limit'] > 0 && ! empty( $where ) ) {
209 $sql .= $wpdb->prepare( ' LIMIT %d', $args['limit'] );
210 }
211
212 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
213 $result = $wpdb->query( $sql );
214
215 // Trigger action to clear cache.
216 if ( false !== $result ) {
217 do_action( 'tptn_delete_counts', $args );
218 }
219
220 return $result;
221 }
222
223 /**
224 * Get table statistics including entry count and size.
225 *
226 * @since 4.2.0
227 *
228 * @return array Array of table statistics with entry count and size.
229 */
230 public static function get_table_statistics() {
231 $cache_key = 'tptn_table_statistics';
232 $stats = wp_cache_get( $cache_key, 'top-10' );
233
234 if ( false === $stats ) {
235 $stats = array();
236
237 $tables = array(
238 'top_ten' => self::get_table( false ),
239 'top_ten_daily' => self::get_table( true ),
240 'top_ten_visits_funnel' => self::get_funnel_table(),
241 'top_ten_visits_log' => self::get_log_table(),
242 );
243
244 foreach ( $tables as $key => $table_name ) {
245 if ( self::is_table_installed( $table_name ) ) {
246 $stats[ $key ] = self::get_single_table_statistics( $table_name );
247 }
248 }
249
250 // Cache for 5 minutes.
251 wp_cache_set( $cache_key, $stats, 'top-10', 300 );
252 }
253
254 /**
255 * Filter the table statistics.
256 *
257 * @since 4.2.0
258 *
259 * @param array $stats Array of table statistics.
260 */
261 return apply_filters( 'tptn_table_statistics', $stats );
262 }
263
264 /**
265 * Get entry count and estimated size for a single table.
266 *
267 * @since 4.3.0
268 *
269 * @param string $table_name Table name.
270 * @return array {
271 * @type int $entries Number of entries.
272 * @type float $size Estimated size in bytes.
273 * }
274 */
275 private static function get_single_table_statistics( $table_name ) {
276 global $wpdb;
277
278 // Get row count.
279 if ( is_network_admin() ) {
280 // In network admin, count all entries.
281 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
282 $count = $wpdb->get_var( "SELECT COUNT(*) FROM `{$table_name}`" );
283 } else {
284 // In individual site admin, count only entries for this blog.
285 $count = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
286 $wpdb->prepare(
287 "SELECT COUNT(*) FROM `{$table_name}` WHERE blog_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
288 get_current_blog_id()
289 )
290 );
291 }
292
293 // Refresh InnoDB stats so information_schema reflects the current state.
294 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
295 $wpdb->query( "ANALYZE TABLE `{$table_name}`" );
296
297 // Get table size in bytes (always shows total size across all blogs).
298 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
299 $size = $wpdb->get_var(
300 $wpdb->prepare(
301 'SELECT (data_length + index_length) FROM information_schema.TABLES WHERE table_schema = %s AND table_name = %s',
302 defined( 'DB_NAME' ) ? DB_NAME : '', // @codingStandardsIgnoreLine - WordPress constant
303 $table_name
304 )
305 );
306
307 // Calculate size for individual sites in multisite.
308 $calculated_size = $size ? (int) $size : 0;
309 if ( is_multisite() && ! is_network_admin() && $calculated_size > 0 ) {
310 // Get total entries to calculate ratio.
311 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
312 $total_count = $wpdb->get_var( "SELECT COUNT(*) FROM `{$table_name}`" );
313
314 if ( $total_count > 0 && $count > 0 ) {
315 // Estimate size based on entry count ratio.
316 $calculated_size = ( $count / $total_count ) * $calculated_size;
317 }
318 }
319
320 return array(
321 'entries' => absint( $count ),
322 'size' => $calculated_size,
323 );
324 }
325
326 /**
327 * Clear the table statistics cache.
328 *
329 * @since 4.2.0
330 */
331 public static function clear_table_statistics_cache() {
332 wp_cache_delete( 'tptn_table_statistics', 'top-10' );
333 }
334
335 /**
336 * Check if a table exists.
337 *
338 * @since 4.2.0
339 *
340 * @param string $table Table name to check.
341 * @return bool True if table exists, false otherwise.
342 */
343 public static function is_table_installed( $table ) {
344 global $wpdb;
345
346 static $cache = array();
347
348 if ( isset( $cache[ $table ] ) ) {
349 return $cache[ $table ];
350 }
351
352 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
353 $result = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) );
354 $cache[ $table ] = ( $result === $table );
355
356 return $cache[ $table ];
357 }
358
359 /**
360 * Get counts with post information (JOIN with wp_posts).
361 *
362 * @since 4.2.0
363 *
364 * @param array $args {
365 * Optional. Array of arguments.
366 *
367 * @type bool $daily Whether to get daily counts.
368 * @type int $blog_id Blog ID to filter by.
369 * @type string $from_date From date for daily counts.
370 * @type string $to_date To date for daily counts.
371 * @type int $limit Number of results to return.
372 * @type int $offset Offset for pagination.
373 * @type string $order Order direction (ASC/DESC).
374 * @type string $post_type Post type to filter by.
375 * @type array $post_ids Specific post IDs to include.
376 * }
377 * @return array Array of results with post and count information.
378 */
379 public static function get_counts_with_posts( $args = array() ) {
380 global $wpdb;
381
382 $defaults = array(
383 'daily' => false,
384 'blog_id' => null,
385 'from_date' => '',
386 'to_date' => '',
387 'limit' => 10,
388 'offset' => 0,
389 'order' => 'DESC',
390 'post_type' => 'post',
391 'post_ids' => array(),
392 );
393 $args = wp_parse_args( $args, $defaults );
394
395 $table = self::get_table( $args['daily'] );
396 $where = array();
397 $join = " LEFT JOIN {$wpdb->posts} ON t.postnumber = {$wpdb->posts}.ID ";
398 $select_col = $args['daily'] ? 'SUM(t.cntaccess) as cntaccess' : 't.cntaccess';
399
400 if ( null !== $args['blog_id'] ) {
401 $where[] = $wpdb->prepare( 't.blog_id = %d', $args['blog_id'] );
402 }
403
404 if ( $args['daily'] ) {
405 if ( ! empty( $args['from_date'] ) ) {
406 $where[] = $wpdb->prepare( 'DATE(t.dp_date) >= DATE(%s)', $args['from_date'] );
407 }
408 if ( ! empty( $args['to_date'] ) ) {
409 $where[] = $wpdb->prepare( 'DATE(t.dp_date) <= DATE(%s)', $args['to_date'] );
410 }
411 }
412
413 if ( ! empty( $args['post_ids'] ) ) {
414 $post_ids = array_map( 'intval', $args['post_ids'] );
415 $where[] = 't.postnumber IN (' . implode( ',', $post_ids ) . ')';
416 }
417
418 $where[] = $wpdb->prepare( "{$wpdb->posts}.post_type = %s", $args['post_type'] );
419 $where[] = "{$wpdb->posts}.post_status = 'publish'";
420
421 $sql = "SELECT t.postnumber, {$select_col}, t.blog_id, {$wpdb->posts}.post_title, {$wpdb->posts}.post_date ";
422 $sql .= "FROM {$table} t {$join}";
423 $sql .= ' WHERE ' . implode( ' AND ', $where );
424
425 if ( $args['daily'] ) {
426 $sql .= " GROUP BY t.postnumber, t.blog_id, {$wpdb->posts}.post_title, {$wpdb->posts}.post_date ";
427 }
428
429 // Sanitize order parameter.
430 $order = in_array( strtoupper( $args['order'] ), array( 'ASC', 'DESC' ), true ) ? strtoupper( $args['order'] ) : 'DESC';
431 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
432 $sql .= $wpdb->prepare( " ORDER BY cntaccess {$order} LIMIT %d OFFSET %d", $args['limit'], $args['offset'] );
433
434 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
435 return $wpdb->get_results( $sql, ARRAY_A );
436 }
437
438 /**
439 * Bulk upsert counts for import operations.
440 *
441 * @since 4.2.0
442 *
443 * @param array $data Array of data to insert. Each element should be an array with postnumber, cntaccess, blog_id keys.
444 * @param bool $daily Whether this is for daily table (includes dp_date).
445 * @return int|false Number of rows affected or false on error.
446 */
447 public static function bulk_upsert( $data, $daily = false ) {
448 global $wpdb;
449
450 if ( empty( $data ) ) {
451 return false;
452 }
453
454 $table = self::get_table( $daily );
455 $values = array();
456
457 foreach ( $data as $row ) {
458 if ( $daily ) {
459 $dp_date = isset( $row['dp_date'] ) ? $row['dp_date'] : current_time( 'Y-m-d H' );
460 $values[] = $wpdb->prepare( '( %d, %d, %s, %d )', $row['postnumber'], $row['cntaccess'], $dp_date, $row['blog_id'] );
461 } else {
462 $values[] = $wpdb->prepare( '( %d, %d, %d )', $row['postnumber'], $row['cntaccess'], $row['blog_id'] );
463 }
464 }
465
466 if ( $daily ) {
467 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
468 $sql = "INSERT INTO {$table} (postnumber, cntaccess, dp_date, blog_id) VALUES " . implode( ',', $values ) . ' ON DUPLICATE KEY UPDATE cntaccess = VALUES(cntaccess)';
469 } else {
470 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
471 $sql = "INSERT INTO {$table} (postnumber, cntaccess, blog_id) VALUES " . implode( ',', $values ) . ' ON DUPLICATE KEY UPDATE cntaccess = VALUES(cntaccess)';
472 }
473
474 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
475 return $wpdb->query( $sql );
476 }
477
478 /**
479 * Get total count for all posts.
480 *
481 * @since 4.2.0
482 *
483 * @param int $blog_id Blog ID (optional, defaults to current blog).
484 * @param bool $daily Whether to get daily total.
485 * @param string $from_date From date for daily counts.
486 * @param string $to_date To date for daily counts.
487 * @return int Total count.
488 */
489 public static function get_total_count( $blog_id = null, $daily = false, $from_date = '', $to_date = '' ) {
490 global $wpdb;
491
492 $blog_id = $blog_id ?? get_current_blog_id();
493 $table = self::get_table( $daily );
494 $where = $wpdb->prepare( 'WHERE blog_id = %d', $blog_id );
495
496 if ( $daily ) {
497 if ( ! empty( $from_date ) ) {
498 $where .= $wpdb->prepare( ' AND dp_date >= %s', $from_date );
499 }
500 if ( ! empty( $to_date ) ) {
501 $where .= $wpdb->prepare( ' AND dp_date <= %s', $to_date );
502 }
503 }
504
505 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
506 $sql = "SELECT SUM(cntaccess) FROM {$table} {$where}";
507
508 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
509 return (int) $wpdb->get_var( $sql );
510 }
511
512 /**
513 * Get popular posts with caching support.
514 *
515 * @since 4.2.0
516 *
517 * @param array $args Query arguments (same format as Top_Ten_Core_Query).
518 * @return array Array of post IDs.
519 */
520 public static function get_popular_posts( $args = array() ) {
521 // This method integrates with the existing Top_Ten_Core_Query class
522 // but provides a simpler interface for basic operations.
523
524 $defaults = array(
525 'daily' => false,
526 'limit' => 10,
527 'post_type' => 'post',
528 'blog_id' => null,
529 );
530 $args = wp_parse_args( $args, $defaults );
531
532 // Use the existing query class for complex operations.
533 $query = new \Top_Ten_Query( $args );
534 $posts = $query->get_posts();
535
536 return wp_list_pluck( $posts, 'ID' );
537 }
538
539 /**
540 * Check if the Top Ten tables exist.
541 *
542 * @since 4.2.0
543 *
544 * @return bool True if both tables exist, false otherwise.
545 */
546 public static function are_tables_installed() {
547 return self::is_table_installed( self::get_table( false ) )
548 && self::is_table_installed( self::get_table( true ) );
549 }
550
551 /**
552 * Create table SQL for the main top_ten table.
553 *
554 * @since 4.2.0
555 *
556 * @return string SQL to create the main table.
557 */
558 public static function create_full_table_sql() {
559 global $wpdb;
560
561 $charset_collate = $wpdb->get_charset_collate();
562 $table_name = $wpdb->base_prefix . 'top_ten';
563
564 $sql = "CREATE TABLE {$table_name}" . // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
565 " (
566 postnumber bigint(20) NOT NULL,
567 cntaccess bigint(20) NOT NULL,
568 blog_id bigint(20) NOT NULL DEFAULT '1',
569 PRIMARY KEY (postnumber, blog_id),
570 KEY idx_blog_id (blog_id)
571 ) $charset_collate;";
572
573 return $sql;
574 }
575
576 /**
577 * Create table SQL for the daily top_ten_daily table.
578 *
579 * @since 4.2.0
580 *
581 * @return string SQL to create the daily table.
582 */
583 public static function create_daily_table_sql() {
584 global $wpdb;
585
586 $charset_collate = $wpdb->get_charset_collate();
587 $table_name = $wpdb->base_prefix . 'top_ten_daily';
588
589 $sql = "CREATE TABLE {$table_name}" . // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
590 " (
591 postnumber bigint(20) NOT NULL,
592 cntaccess bigint(20) NOT NULL,
593 dp_date DATETIME NOT NULL,
594 blog_id bigint(20) NOT NULL DEFAULT '1',
595 PRIMARY KEY (postnumber, dp_date, blog_id),
596 KEY blog_date (blog_id, dp_date, postnumber),
597 KEY idx_dp_date (dp_date)
598 ) $charset_collate;";
599
600 return $sql;
601 }
602
603 /**
604 * Get the name of the visits funnel table (hot buffer, drained every 5 minutes).
605 *
606 * @since 4.3.0
607 *
608 * @return string Table name.
609 */
610 public static function get_funnel_table() {
611 global $wpdb;
612 return $wpdb->base_prefix . 'top_ten_visits_funnel';
613 }
614
615 /**
616 * SQL to create the visits funnel table.
617 *
618 * @since 4.3.0
619 *
620 * @return string CREATE TABLE SQL.
621 */
622 public static function create_funnel_table_sql() {
623 global $wpdb;
624
625 $charset_collate = $wpdb->get_charset_collate();
626 $table_name = self::get_funnel_table();
627
628 $sql = "CREATE TABLE {$table_name}" . // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
629 " (
630 id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
631 postnumber bigint(20) UNSIGNED NOT NULL,
632 blog_id bigint(20) UNSIGNED NOT NULL DEFAULT '1',
633 visited_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
634 activate_counter tinyint(2) UNSIGNED NOT NULL DEFAULT '11',
635 source tinyint(2) UNSIGNED NOT NULL DEFAULT '0',
636 PRIMARY KEY (id)
637 ) $charset_collate;";
638
639 return $sql;
640 }
641
642 /**
643 * Get the name of the visits log table (cold archive, pruned by maintenance cron).
644 *
645 * @since 4.3.0
646 *
647 * @return string Table name.
648 */
649 public static function get_log_table() {
650 global $wpdb;
651 return $wpdb->base_prefix . 'top_ten_visits_log';
652 }
653
654 /**
655 * SQL to create the visits log table.
656 *
657 * @since 4.3.0
658 *
659 * @return string CREATE TABLE SQL.
660 */
661 public static function create_log_table_sql() {
662 global $wpdb;
663
664 $charset_collate = $wpdb->get_charset_collate();
665 $table_name = self::get_log_table();
666
667 $sql = "CREATE TABLE {$table_name}" . // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
668 " (
669 id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
670 postnumber bigint(20) UNSIGNED NOT NULL,
671 blog_id bigint(20) UNSIGNED NOT NULL DEFAULT '1',
672 visited_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
673 source tinyint(2) UNSIGNED NOT NULL DEFAULT '0',
674 PRIMARY KEY (id),
675 KEY idx_visited_at (visited_at)
676 ) $charset_collate;";
677
678 return $sql;
679 }
680
681 /**
682 * Append a single visit to the funnel table.
683 *
684 * @since 4.3.0
685 *
686 * @param int $post_id Post ID.
687 * @param int $blog_id Blog ID.
688 * @param int $activate_counter Counter flag: 1 = overall, 10 = daily, 11 = both.
689 * @param int $source Traffic source: 0 = web, 1 = feed.
690 * @return int|false Rows inserted or false on error.
691 */
692 public static function append_to_funnel( $post_id, $blog_id, $activate_counter = 11, $source = 0 ) {
693 global $wpdb;
694
695 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
696 return $wpdb->insert(
697 self::get_funnel_table(),
698 array(
699 'postnumber' => absint( $post_id ),
700 'blog_id' => absint( $blog_id ),
701 'visited_at' => current_time( 'mysql' ),
702 'activate_counter' => (int) $activate_counter,
703 'source' => (int) $source,
704 ),
705 array( '%d', '%d', '%s', '%d', '%d' )
706 );
707 }
708
709 /**
710 * Drain the funnel into the log and count tables, then empty the funnel.
711 *
712 * All four operations (copy to log, aggregate to daily, aggregate to overall,
713 * delete from funnel) run inside one transaction. A failure rolls back cleanly
714 * and the next run retries the same rows with no double-counting risk.
715 *
716 * @since 4.3.0
717 *
718 * @param int $batch_size Maximum funnel rows to process per run.
719 * @return bool True if rows were processed, false if none found or lock not acquired.
720 */
721 public static function aggregate_visit_log( $batch_size = 10000 ) {
722 global $wpdb;
723
724 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
725 $lock_acquired = $wpdb->get_var( "SELECT GET_LOCK('tptn_aggregation', 0)" );
726 if ( '1' !== (string) $lock_acquired ) {
727 return false;
728 }
729
730 $funnel_table = self::get_funnel_table();
731 $log_table = self::get_log_table();
732 $daily_table = self::get_table( true );
733 $full_table = self::get_table( false );
734
735 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
736 if ( false === $wpdb->query( 'START TRANSACTION' ) ) {
737 $wpdb->query( "SELECT RELEASE_LOCK('tptn_aggregation')" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
738 return false;
739 }
740
741 try {
742 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
743 $max_id = (int) $wpdb->get_var( "SELECT MAX(id) FROM {$funnel_table}" );
744 if ( 0 === $max_id ) {
745 $wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
746 return false;
747 }
748
749 $cap_id = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$funnel_table} ORDER BY id ASC LIMIT %d, 1", $batch_size ) );
750 $was_capped = false;
751 if ( null !== $cap_id ) {
752 $capped_max = (int) $cap_id - 1;
753 if ( $capped_max > 0 ) {
754 $max_id = $capped_max;
755 $was_capped = true;
756 }
757 }
758
759 $r = $wpdb->query(
760 $wpdb->prepare(
761 "INSERT INTO {$log_table} (postnumber, blog_id, visited_at, source)
762 SELECT postnumber, blog_id, visited_at, source
763 FROM {$funnel_table}
764 WHERE id <= %d",
765 $max_id
766 )
767 );
768 if ( false === $r ) {
769 $wpdb->query( 'ROLLBACK' );
770 return false;
771 }
772
773 $r = $wpdb->query(
774 $wpdb->prepare(
775 "INSERT INTO {$daily_table} (postnumber, cntaccess, dp_date, blog_id)
776 SELECT * FROM (
777 SELECT postnumber, COUNT(*) AS cntaccess,
778 DATE_FORMAT(visited_at, '%%Y-%%m-%%d %%H:00:00') AS dp_date, blog_id
779 FROM {$funnel_table}
780 WHERE id <= %d AND activate_counter IN (10, 11)
781 GROUP BY postnumber, DATE_FORMAT(visited_at, '%%Y-%%m-%%d %%H:00:00'), blog_id
782 ) AS new_row
783 ON DUPLICATE KEY UPDATE cntaccess = {$daily_table}.cntaccess + new_row.cntaccess",
784 $max_id
785 )
786 );
787 if ( false === $r ) {
788 $wpdb->query( 'ROLLBACK' );
789 return false;
790 }
791
792 $r = $wpdb->query(
793 $wpdb->prepare(
794 "INSERT INTO {$full_table} (postnumber, cntaccess, blog_id)
795 SELECT * FROM (
796 SELECT postnumber, COUNT(*) AS cntaccess, blog_id
797 FROM {$funnel_table}
798 WHERE id <= %d AND activate_counter IN (1, 11)
799 GROUP BY postnumber, blog_id
800 ) AS new_row
801 ON DUPLICATE KEY UPDATE cntaccess = {$full_table}.cntaccess + new_row.cntaccess",
802 $max_id
803 )
804 );
805 if ( false === $r ) {
806 $wpdb->query( 'ROLLBACK' );
807 return false;
808 }
809
810 $r = $wpdb->query( $wpdb->prepare( "DELETE FROM {$funnel_table} WHERE id <= %d", $max_id ) );
811 if ( false === $r ) {
812 $wpdb->query( 'ROLLBACK' );
813 return false;
814 }
815 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
816
817 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
818 if ( false === $wpdb->query( 'COMMIT' ) ) {
819 $wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
820 return false;
821 }
822
823 do_action( 'tptn_count_updated', 0, 0, false );
824
825 if ( $was_capped && ! wp_next_scheduled( 'tptn_aggregation_cron_hook' ) ) {
826 wp_schedule_single_event( time(), 'tptn_aggregation_cron_hook' );
827 }
828
829 return true;
830 } finally {
831 $wpdb->query( "SELECT RELEASE_LOCK('tptn_aggregation')" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
832 }
833 }
834
835 /**
836 * Recreate a table.
837 *
838 * This method recreates a table by creating a backup, dropping the original table,
839 * and then creating a new table with the original name and inserting the data from the backup.
840 *
841 * @since 4.2.0
842 *
843 * @param string $table_name The name of the table to recreate.
844 * @param string $create_table_sql The SQL statement to create the new table.
845 * @param bool $backup Whether to backup the table or not.
846 * @param array $fields The fields to include in the temporary table and on duplicate key code.
847 * @param array $group_by_fields The fields to group by in the temporary table.
848 *
849 * @return bool|\WP_Error True if recreated, error message if failed.
850 */
851 public static function recreate_table(
852 $table_name,
853 $create_table_sql,
854 $backup = true,
855 $fields = array( 'postnumber', 'cntaccess', 'blog_id' ),
856 $group_by_fields = array( 'postnumber', 'blog_id' )
857 ) {
858 global $wpdb;
859
860 $backup_table_name = ( $backup ) ? $table_name . '_backup' : $table_name . '_temp';
861 $success = false;
862
863 $fields_sql = implode( ', ', $fields );
864 $fields_sql_with_sum = str_replace( 'cntaccess', 'SUM(cntaccess) as cntaccess', $fields_sql );
865 $group_by_sql = implode( ', ', $group_by_fields );
866
867 if ( $backup ) {
868 $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
869 if ( false !== $success ) {
870 $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
871 } else {
872 /* translators: 1: Site number, 2: Error message */
873 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 ) );
874 }
875 } else {
876 $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
877 $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
878 }
879
880 if ( false !== $success ) {
881 $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
882
883 // Direct table creation without dbDelta for recreation.
884 $wpdb->query( $create_table_sql ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
885
886 $insert_fields_sql = 'tt.' . implode( ', tt.', $fields );
887
888 $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
889
890 if ( false === $success ) {
891 /* translators: 1: Site number, 2: Error message */
892 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 ) );
893 }
894 }
895
896 if ( ! $backup ) {
897 $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
898 }
899
900 return $success;
901 }
902
903 /**
904 * Recreate overall table.
905 *
906 * @since 4.2.0
907 *
908 * @param bool $backup Whether to backup the table or not.
909 *
910 * @return bool|\WP_Error True if recreated, error message if failed.
911 */
912 public static function recreate_overall_table( $backup = true ) {
913 global $wpdb;
914 return self::recreate_table(
915 $wpdb->base_prefix . 'top_ten',
916 self::create_full_table_sql(),
917 $backup
918 );
919 }
920
921 /**
922 * Recreate daily table.
923 *
924 * @since 4.2.0
925 *
926 * @param bool $backup Whether to backup the table or not.
927 *
928 * @return bool|\WP_Error True if recreated, error message if failed.
929 */
930 public static function recreate_daily_table( $backup = true ) {
931 global $wpdb;
932 return self::recreate_table(
933 $wpdb->base_prefix . 'top_ten_daily',
934 self::create_daily_table_sql(),
935 $backup,
936 array( 'postnumber', 'cntaccess', 'dp_date', 'blog_id' ),
937 array( 'postnumber', 'dp_date', 'blog_id' )
938 );
939 }
940
941 /**
942 * Truncate a table.
943 *
944 * @since 4.2.0
945 *
946 * @param string $table_name Table name to truncate.
947 * @return bool True on success, false on failure.
948 */
949 public static function truncate_table( $table_name ) {
950 global $wpdb;
951
952 // Table names cannot be parameterized in TRUNCATE statements.
953 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
954 return $wpdb->query( "TRUNCATE TABLE $table_name" );
955 }
956
957 /**
958 * Count rows in the daily table that would be pruned up to a given date.
959 *
960 * @since 4.3.0
961 *
962 * @param string $to_date Rows with dp_date at or before this value are counted.
963 * @return int Row count.
964 */
965 public static function count_deletable_daily_rows( string $to_date ): int {
966 global $wpdb;
967 $table = self::get_table( true );
968 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
969 return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE dp_date <= %s", $to_date ) );
970 }
971
972 /**
973 * Count rows in the visits log table older than a given datetime.
974 *
975 * @since 4.3.0
976 *
977 * @param string $before_datetime Rows with visited_at before this value are counted.
978 * @return int Row count.
979 */
980 public static function count_deletable_log_rows( string $before_datetime ): int {
981 global $wpdb;
982 $table = self::get_log_table();
983 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
984 return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE visited_at < %s", $before_datetime ) );
985 }
986
987 /**
988 * Delete rows from the visits log table older than a given datetime.
989 *
990 * @since 4.3.0
991 *
992 * @param string $before_datetime Rows with visited_at before this value are deleted.
993 * @param int $batch_size Maximum rows to delete per call.
994 * @return int|false Rows deleted, or false on failure.
995 */
996 public static function prune_log_table( string $before_datetime, int $batch_size = 1000 ) {
997 global $wpdb;
998 $table = self::get_log_table();
999 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1000 return $wpdb->query( $wpdb->prepare( "DELETE FROM `{$table}` WHERE visited_at < %s LIMIT %d", $before_datetime, $batch_size ) );
1001 }
1002
1003 /**
1004 * Count rows in the visits funnel table.
1005 *
1006 * @since 4.3.0
1007 *
1008 * @return int Row count.
1009 */
1010 public static function count_funnel_rows(): int {
1011 global $wpdb;
1012 $table = self::get_funnel_table();
1013 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1014 return (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
1015 }
1016
1017 /**
1018 * Count orphaned rows in a count table (rows with no matching post).
1019 *
1020 * Only inspects rows belonging to the current blog so that posts on other
1021 * sites in a multisite network are not falsely reported as orphans.
1022 *
1023 * @since 4.3.0
1024 *
1025 * @param string $table_name Count table to inspect.
1026 * @return int Row count.
1027 */
1028 public static function count_orphan_counts( string $table_name ): int {
1029 global $wpdb;
1030 $blog_id = get_current_blog_id();
1031 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1032 return (int) $wpdb->get_var(
1033 $wpdb->prepare(
1034 "SELECT COUNT(*) FROM `{$table_name}` t
1035 LEFT JOIN `{$wpdb->posts}` p ON t.postnumber = p.ID
1036 WHERE p.ID IS NULL AND t.blog_id = %d",
1037 $blog_id
1038 )
1039 );
1040 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1041 }
1042
1043 /**
1044 * Delete orphaned rows from a count table (rows with no matching post).
1045 *
1046 * Only deletes rows belonging to the current blog so that posts on other
1047 * sites in a multisite network are not falsely treated as orphans.
1048 *
1049 * @since 4.3.0
1050 *
1051 * @param string $table_name Count table to clean.
1052 * @param int $batch_size Maximum rows to delete per call.
1053 * @return int|false Rows deleted, or false on failure.
1054 */
1055 public static function delete_orphan_counts( string $table_name, int $batch_size = 1000 ) {
1056 global $wpdb;
1057 $blog_id = get_current_blog_id();
1058 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1059 return $wpdb->query(
1060 $wpdb->prepare(
1061 "DELETE t FROM `{$table_name}` t
1062 LEFT JOIN `{$wpdb->posts}` p ON t.postnumber = p.ID
1063 WHERE p.ID IS NULL AND t.blog_id = %d
1064 LIMIT %d",
1065 $blog_id,
1066 $batch_size
1067 )
1068 );
1069 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1070 }
1071 }
1072