diff --git a/inc/class-statify-api.php b/inc/class-statify-api.php index 99e69f95..c11afc0e 100644 --- a/inc/class-statify-api.php +++ b/inc/class-statify-api.php @@ -108,7 +108,9 @@ public static function track_visit( $request ) { $target = filter_var( $target, FILTER_SANITIZE_URL ); } - Statify::track( $referrer, $target ); + $meta = $request->get_param( 'meta' ); + + Statify::track( $referrer, $target, $meta ); } return new WP_REST_Response( null, 204 ); diff --git a/inc/class-statify-cron.php b/inc/class-statify-cron.php index 969c7704..7af0b93a 100644 --- a/inc/class-statify-cron.php +++ b/inc/class-statify-cron.php @@ -23,13 +23,12 @@ class Statify_Cron extends Statify { * * @since 0.3.0 * @version 1.4.0 + * @wp-hook boolean statify__skip_aggregation */ public static function cleanup_data() { - - // Global. global $wpdb; - // Remove items. + // Remove old items. $wpdb->query( $wpdb->prepare( "DELETE FROM `$wpdb->statify` WHERE created <= SUBDATE(%s, %d)", @@ -38,9 +37,75 @@ public static function cleanup_data() { ) ); + // Aggregate data. + if ( ! apply_filters( 'statify__skip_aggregation', false ) ) { + self::aggregate_data(); + } + // Optimize DB. - $wpdb->query( - "OPTIMIZE TABLE `$wpdb->statify`" - ); + $wpdb->query( "OPTIMIZE TABLE `$wpdb->statify`" ); + } + + /** + * Aggregate data in database. + * + * @since 1.9 + */ + public static function aggregate_data() { + global $wpdb; + + // Get date of last aggregation. + if ( isset( self::$_options['last_aggregation'] ) ) { + // Value saved, use it. + $start = self::$_options['last_aggregation']; + } else { + // No? We need to clean up all data. Let's determine the oldest data in the database. + $start = $wpdb->get_col( "SELECT MIN(`created`) FROM `$wpdb->statify`" ); + $start = $start[0]; + } + + if ( is_null( $start ) ) { + // No data available, i.e not cleaned up yet and no data in database. + return; + } + + $now = new DateTime(); + $date = new DateTime( $start ); + + // Iterate over every day from start (inclusive) til now. + while ( $date < $now ) { + $agg = $wpdb->get_results( + $wpdb->prepare( + "SELECT `created`, `referrer`, `target`, SUM(`hits`) as `hits` FROM `$wpdb->statify` WHERE `created` = %s GROUP BY `created`, `referrer`, `target`", + $date->format( 'Y-m-d' ) + ), + ARRAY_A + ); + + // Remove non-aggregated data and insert aggregates within one transaction. + $wpdb->query( 'START TRANSACTION' ); + $res = $wpdb->query( + $wpdb->prepare( + "DELETE FROM `$wpdb->statify` WHERE `created` = %s", + $date->format( 'Y-m-d' ) + ) + ); + if ( false !== $res ) { + foreach ( $agg as $a ) { + if ( false === $wpdb->insert( $wpdb->statify, $a ) ) { + $wpdb->query( 'ROLLBACK' ); + break; + } + } + } + $wpdb->query( 'COMMIT' ); + + // Continue with next day. + $date->modify( '+1 day' ); + } + + // Remember last aggregation date. + self::$_options['last_aggregation'] = $now->format( 'Y-m-d' ); + update_option( 'statify', self::$_options ); } } diff --git a/inc/class-statify-dashboard.php b/inc/class-statify-dashboard.php index cf8f7c44..00530435 100755 --- a/inc/class-statify-dashboard.php +++ b/inc/class-statify-dashboard.php @@ -320,7 +320,7 @@ private static function _select_data() { $data = array( 'visits' => $wpdb->get_results( $wpdb->prepare( - "SELECT `created` as `date`, COUNT(`created`) as `count` FROM `$wpdb->statify` GROUP BY `created` ORDER BY `created` DESC LIMIT %d", + "SELECT `created` as `date`, SUM(`hits`) as `count` FROM `$wpdb->statify` GROUP BY `created` ORDER BY `created` DESC LIMIT %d", $days_show ), ARRAY_A @@ -330,7 +330,7 @@ private static function _select_data() { if ( $today ) { $data['target'] = $wpdb->get_results( $wpdb->prepare( - "SELECT COUNT(`target`) as `count`, `target` as `url` FROM `$wpdb->statify` WHERE created = %s GROUP BY `target` ORDER BY `count` DESC, `url` ASC LIMIT %d", + "SELECT SUM(`hits`) as `count`, `target` as `url` FROM `$wpdb->statify` WHERE created = %s GROUP BY `target` ORDER BY `count` DESC, `url` ASC LIMIT %d", $current_date, $limit ), @@ -338,7 +338,7 @@ private static function _select_data() { ); $data['referrer'] = $wpdb->get_results( $wpdb->prepare( - "SELECT COUNT(`referrer`) as `count`, `referrer` as `url`, SUBSTRING_INDEX(SUBSTRING_INDEX(TRIM(LEADING 'www.' FROM(TRIM(LEADING 'https://' FROM TRIM(LEADING 'http://' FROM TRIM(`referrer`))))), '/', 1), ':', 1) as `host` FROM `$wpdb->statify` WHERE `referrer` != '' AND created = %s GROUP BY `host` ORDER BY `count` DESC, `url` ASC LIMIT %d", + "SELECT SUM(`hits`) as `count`, `referrer` as `url`, SUBSTRING_INDEX(SUBSTRING_INDEX(TRIM(LEADING 'www.' FROM(TRIM(LEADING 'https://' FROM TRIM(LEADING 'http://' FROM TRIM(`referrer`))))), '/', 1), ':', 1) as `host` FROM `$wpdb->statify` WHERE `referrer` != '' AND created = %s GROUP BY `host` ORDER BY `count` DESC, `url` ASC LIMIT %d", $current_date, $limit ), @@ -347,7 +347,7 @@ private static function _select_data() { } else { $data['target'] = $wpdb->get_results( $wpdb->prepare( - "SELECT COUNT(`target`) as `count`, `target` as `url` FROM `$wpdb->statify` WHERE created > DATE_SUB(%s, INTERVAL %d DAY) GROUP BY `target` ORDER BY `count` DESC, `url` ASC LIMIT %d", + "SELECT SUM(`hits`) as `count`, `target` as `url` FROM `$wpdb->statify` WHERE created > DATE_SUB(%s, INTERVAL %d DAY) GROUP BY `target` ORDER BY `count` DESC, `url` ASC LIMIT %d", $current_date, $days_show, $limit @@ -356,7 +356,7 @@ private static function _select_data() { ); $data['referrer'] = $wpdb->get_results( $wpdb->prepare( - "SELECT COUNT(`referrer`) as `count`, `referrer` as `url`, SUBSTRING_INDEX(SUBSTRING_INDEX(TRIM(LEADING 'www.' FROM(TRIM(LEADING 'https://' FROM TRIM(LEADING 'http://' FROM TRIM(`referrer`))))), '/', 1), ':', 1) as `host` FROM `$wpdb->statify` WHERE `referrer` != '' AND created > DATE_SUB(%s, INTERVAL %d DAY) GROUP BY `host` ORDER BY `count` DESC, `url` ASC LIMIT %d", + "SELECT SUM(`hits`) as `count`, `referrer` as `url`, SUBSTRING_INDEX(SUBSTRING_INDEX(TRIM(LEADING 'www.' FROM(TRIM(LEADING 'https://' FROM TRIM(LEADING 'http://' FROM TRIM(`referrer`))))), '/', 1), ':', 1) as `host` FROM `$wpdb->statify` WHERE `referrer` != '' AND created > DATE_SUB(%s, INTERVAL %d DAY) GROUP BY `host` ORDER BY `count` DESC, `url` ASC LIMIT %d", $current_date, $days_show, $limit @@ -369,12 +369,12 @@ private static function _select_data() { $data['visit_totals'] = array( 'today' => $wpdb->get_var( $wpdb->prepare( - "SELECT COUNT(`created`) FROM `$wpdb->statify` WHERE created = %s", + "SELECT SUM(`hits`) FROM `$wpdb->statify` WHERE created = %s", $current_date ) ), 'since_beginning' => $wpdb->get_row( - "SELECT COUNT(`created`) AS `count`, MIN(`created`) AS `date` FROM `$wpdb->statify`", + "SELECT SUM(`hits`) AS `count`, MIN(`created`) AS `date` FROM `$wpdb->statify`", ARRAY_A ), ); diff --git a/inc/class-statify-frontend.php b/inc/class-statify-frontend.php index 7153e8c7..9a9575d0 100644 --- a/inc/class-statify-frontend.php +++ b/inc/class-statify-frontend.php @@ -18,6 +18,29 @@ */ class Statify_Frontend extends Statify { + /** + * Returns key value pair array with tracking metadata. + * + * @return array + */ + private static function load_metadata() { + $meta = array(); + + foreach ( Statify::get_metafields() as $field ) { + // Get values. + if ( + isset( $field['key'] ) && + is_string( $field['key'] ) && + isset( $field['callback'] ) && + is_callable( $field['callback'] ) + ) { + $meta[ $field['key'] ] = call_user_func( $field['callback'] ); + } + } + + return $meta; + } + /** * Track the page view * @@ -44,7 +67,7 @@ public static function track_visit( $is_snippet = false ) { $referrer = filter_var( wp_unslash( $_SERVER['HTTP_REFERER'] ), FILTER_SANITIZE_URL ); } - Statify::track( $referrer, $target ); + Statify::track( $referrer, $target, self::load_metadata() ); } /** @@ -94,9 +117,10 @@ public static function wp_footer() { true ); - // Add endpoint to script. + // Add endpoint and tracking meta data to script. $script_data = array( 'url' => esc_url_raw( rest_url( Statify_Api::REST_NAMESPACE . '/' . Statify_Api::REST_ROUTE_TRACK ) ), + 'tracking_meta' => self::load_metadata(), ); if ( Statify::TRACKING_METHOD_JAVASCRIPT_WITH_NONCE_CHECK === self::$_options['snippet'] ) { $script_data['nonce'] = wp_create_nonce( 'statify_track' ); diff --git a/inc/class-statify-install.php b/inc/class-statify-install.php index ee7ecf44..c685cddc 100644 --- a/inc/class-statify-install.php +++ b/inc/class-statify-install.php @@ -82,8 +82,7 @@ private static function _apply() { ); } - // Create the actual tables. - Statify_Table::init(); - Statify_Table::create(); + // Initialize the database schema. + Statify_Schema::init(); } } diff --git a/inc/class-statify-schema.php b/inc/class-statify-schema.php new file mode 100644 index 00000000..505400f9 --- /dev/null +++ b/inc/class-statify-schema.php @@ -0,0 +1,127 @@ +tables[] = $table; + $wpdb->$table = $wpdb->get_blog_prefix() . $table; + } + + self::maybe_create_tables(); + } + + /** + * Create the tables. + * + * @since 2.0.0 + * @version 2.0.0 + */ + public static function maybe_create_tables() { + $current_db_version = get_option( 'statify_db_version', '1.8.4' ); + if ( $current_db_version === self::$db_version ) { + return; + } + + // Global. + global $wpdb, $charset_collate; + + /** + * Use same index length like the WordPress core + * + * @see wp_get_db_schema() + */ + $max_index_length = 191; + + // Include. + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + // Create statify table. + dbDelta( + "CREATE TABLE {$wpdb->statify} ( + id bigint(20) unsigned NOT NULL auto_increment, + created date NOT NULL default '0000-00-00', + referrer varchar(255) NOT NULL default '', + target varchar(255) NOT NULL default '', + hits integer NOT NULL default 1, + PRIMARY KEY (id), + KEY referrer (referrer), + KEY target (target), + KEY created (created) + ) {$charset_collate};" + ); + + // Create statifymeta table. + dbDelta( + "CREATE TABLE {$wpdb->statifymeta} ( + meta_id bigint(20) unsigned NOT NULL auto_increment, + statify_id bigint(20) unsigned NOT NULL default 0, + meta_key varchar(255) default NULL, + meta_value longtext, + PRIMARY KEY (meta_id), + KEY statify_id (statify_id), + KEY meta_key (meta_key({$max_index_length})) + ) {$charset_collate};" + ); + + update_option( 'statify_db_version', self::$db_version ); + } + + /** + * Remove the custom tables. + * + * @since 2.0.0 + * @version 2.0.0 + */ + public static function drop_tables() { + global $wpdb; + + // Remove. + foreach ( static::$tables as $table ) { + $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + delete_option( 'statify_db_version' ); + } +} diff --git a/inc/class-statify-table.php b/inc/class-statify-table.php deleted file mode 100644 index 05b375c4..00000000 --- a/inc/class-statify-table.php +++ /dev/null @@ -1,90 +0,0 @@ -tables[] = $table; - - // With prefix. - $wpdb->$table = $wpdb->get_blog_prefix() . $table; - } - - - /** - * Create the table. - * - * @since 0.6.0 - * @version 1.2.4 - */ - public static function create() { - - global $wpdb; - - // If existent. - if ( $wpdb->get_var( "SHOW TABLES LIKE '$wpdb->statify'" ) === $wpdb->statify ) { - return; - } - - // Include. - require_once ABSPATH . 'wp-admin/includes/upgrade.php'; - - // Create. - dbDelta( - "CREATE TABLE `$wpdb->statify` ( - `id` bigint(20) unsigned NOT NULL auto_increment, - `created` date NOT NULL default '0000-00-00', - `referrer` varchar(255) NOT NULL default '', - `target` varchar(255) NOT NULL default '', - PRIMARY KEY (`id`), - KEY `referrer` (`referrer`), - KEY `target` (`target`), - KEY `created` (`created`) - );" - ); - } - - - /** - * Remove the custom table. - * - * @since 0.6.0 - * @version 1.2.4 - */ - public static function drop() { - - global $wpdb; - - // Remove. - $wpdb->query( "DROP TABLE IF EXISTS `$wpdb->statify`" ); - } -} diff --git a/inc/class-statify-uninstall.php b/inc/class-statify-uninstall.php index f9f1cb3f..493dd6f5 100644 --- a/inc/class-statify-uninstall.php +++ b/inc/class-statify-uninstall.php @@ -70,10 +70,10 @@ private static function _apply() { // Delete options. delete_option( 'statify' ); - // Init table. - Statify_Table::init(); + // Initialize the database schema. + Statify_Schema::init(); - // Delete table. - Statify_Table::drop(); + // Delete tables. + Statify_Schema::drop_tables(); } } diff --git a/inc/class-statify.php b/inc/class-statify.php index 398d1385..144e8156 100755 --- a/inc/class-statify.php +++ b/inc/class-statify.php @@ -44,8 +44,8 @@ public static function init() { return; } - // Table init. - Statify_Table::init(); + // Initialize the database schema. + Statify_Schema::init(); // Plugin options. self::$_options = wp_parse_args( @@ -93,11 +93,31 @@ public static function init() { } } + /** + * Returns meta fields which should be tracked. + * + * @return array + */ + public static function get_metafields() { + $meta = array( + array( + 'key' => 'title', + 'callback' => 'wp_get_document_title', + 'sanitize_callback' => 'sanitize_text_field', + ), + ); + + $meta = apply_filters( 'statify__get_metafields', $meta ); + + return is_array( $meta ) ? $meta : array(); + } + /** * Track the page view. * * @param string|null $referrer Referrer URL. * @param string|null $target Target URL. + * @param array $meta Meta field data. * * @return void * @@ -105,7 +125,7 @@ public static function init() { * @since 1.7.0 $is_snippet parameter added. * @since 2.0.0 Migration from Statify_Frontend::track_visit to Statify::track with multiple parameters. */ - protected static function track( $referrer, $target ) { + protected static function track( $referrer, $target, $meta = array() ) { // Fallbacks for uninitialized or omitted target and referrer values. if ( is_null( $target ) ) { $target = '/'; @@ -134,7 +154,7 @@ protected static function track( $referrer, $target ) { // Relative target URL. $target = user_trailingslashit( str_replace( home_url( '/', 'relative' ), '/', $target ) ); - /* Global vars */ + // Global vars. global $wp_rewrite; // Trim target URL. @@ -147,12 +167,41 @@ protected static function track( $referrer, $target ) { 'created' => current_time( 'Y-m-d' ), 'referrer' => $referrer, 'target' => $target, + 'hits' => 1, ); // Insert. global $wpdb; $wpdb->insert( $wpdb->statify, $data ); + // Meta fields. + if ( is_array( $meta ) ) { + $statify_id = $wpdb->insert_id; + + foreach ( self::get_metafields() as $field ) { + if ( isset( $field['key'] ) && array_key_exists( $field['key'], $meta ) ) { + $value = $meta[ $field['key'] ]; + + // Sanitizing. + $sanitize_function = isset( $field['sanitize_callback'] ) && is_callable( $field['sanitize_callback'] ) + ? $field['sanitize_callback'] + : 'sanitize_text_field'; + + $value = call_user_func( $sanitize_function, $value ); + + // Init rows. + $data = array( + 'statify_id' => $statify_id, + 'meta_key' => $field['meta_key'], + 'meta_value' => $value, + ); + + // Insert. + $wpdb->insert( $wpdb->statifymeta, $data ); + } + } + } + /** * Fires after a visit was stored in the database * @@ -395,4 +444,23 @@ private static function strposa( $haystack, array $needle, $offset = 0 ) { return false; } + + /** + * Retrieves statify metadata for the given statify ID. + * + * @param int $statify_id Statify ID. + * @param string $meta_key Optional. The meta key to retrieve. By default, + * returns data for all keys. Default empty. + * @param bool $single Optional. Whether to return a single value. + * This parameter has no effect if `$key` is not specified. + * Default false. + * + * @return mixed An array of values if `$single` is false. + * The value of the meta field if `$single` is true. + * False for an invalid `$statify_id` (non-numeric, zero, or negative value). + * An empty string if a valid but non-existing statify ID is passed. + */ + public static function get_meta( $statify_id, $meta_key = '', $single = false ) { + return get_metadata( 'statify', $statify_id, $meta_key, $single ); + } } diff --git a/js/snippet.js b/js/snippet.js index 23f58f1c..0911991a 100644 --- a/js/snippet.js +++ b/js/snippet.js @@ -8,6 +8,7 @@ data = { referrer: document.referrer, target: location.pathname + location.search, + meta: statifyAjax.tracking_meta, }; if ( 'nonce' in statifyAjax ) { data.nonce = statifyAjax.nonce; diff --git a/statify.php b/statify.php index d98aed50..203c1b1f 100644 --- a/statify.php +++ b/statify.php @@ -73,7 +73,7 @@ function statify_autoload( $class ) { 'Statify_Uninstall', 'Statify_Deactivate', 'Statify_Settings', - 'Statify_Table', + 'Statify_Schema', 'Statify_XMLRPC', 'Statify_Cron', ); diff --git a/tests/test-cron.php b/tests/test-cron.php index 9d8d689e..57f5037f 100644 --- a/tests/test-cron.php +++ b/tests/test-cron.php @@ -29,6 +29,8 @@ public function set_up() { * @preserveGlobalState disabled */ public function test_cronjob() { + global $wpdb; + // Initialize normal cycle, configure storage period of 3 days. $this->init_statify_widget( 3 ); $this->assertNotFalse( @@ -61,7 +63,8 @@ public function test_cronjob() { $this->assertEquals( 2, $v['count'], 'Unexpected visit count' ); } - // Run the cron job. + // Run the cron job without aggregation. + add_filter( 'statify__skip_aggregation', '__return_true' ); Statify_Cron::cleanup_data(); // Verify that 2 days have been deleted. @@ -72,5 +75,66 @@ public function test_cronjob() { $this->assertContains( $v['date'], $remaining_dates, 'Unexpected remaining date in stats' ); $this->assertEquals( 2, $v['count'], 'Unexpected visit count' ); } + $this->assertEquals( + 6, + $wpdb->get_var( "SELECT COUNT(*) FROM `$wpdb->statify`" ), + 'Unexpected number of entries after cleanup without aggregation' + ); + + // Run the cron job with aggregation (default). + remove_filter( 'statify__skip_aggregation', '__return_true' ); + Statify_Cron::cleanup_data(); + $this->assertEquals( + 3, + $wpdb->get_var( "SELECT COUNT(*) FROM `$wpdb->statify`" ), + 'Unexpected number of entries after cleanup with aggregation' + ); + } + + /** + * Test Statify Cron Job execution. + * + * @runInSeparateProcess Must not preserve global constant. + * @preserveGlobalState disabled + */ + public function test_aggregation() { + global $wpdb; + + // Insert test data: 2 days with 3 and 4 distinct combinations of referrer and target. + $date = new DateTime(); + $this->insert_test_data( $date->format( 'Y-m-d' ), '', '', 2 ); + $this->insert_test_data( $date->format( 'Y-m-d' ), 'https://statify.pluginkollektiv.org/', '/', 3 ); + $this->insert_test_data( $date->format( 'Y-m-d' ), 'https://statify.pluginkollektiv.org/', '/test/', 4 ); + $this->insert_test_data( $date->format( 'Y-m-d' ), 'https://pluginkollektiv.org/', '/', 5 ); + $date->modify( '-1 days' ); + $this->insert_test_data( $date->format( 'Y-m-d' ), 'https://statify.pluginkollektiv.org/', '/', 4 ); + $this->insert_test_data( $date->format( 'Y-m-d' ), 'https://statify.pluginkollektiv.org/', '/test/', 3 ); + $this->insert_test_data( $date->format( 'Y-m-d' ), 'https://pluginkollektiv.org/', '/', 2 ); + + // Get baseline. + $this->assertEquals( 23, $wpdb->get_var( "SELECT COUNT(*) FROM `$wpdb->statify`" ), 'Unexpected number of entries before aggregation' ); + $stats = $this->get_stats(); + + // Trigger aggregation. + Statify_Cron::aggregate_data(); + + // Verify results. + $this->assertEquals( 7, $wpdb->get_var( "SELECT COUNT(*) FROM `$wpdb->statify`" ), 'Unexpected number of entries after aggregation' ); + $stats2 = $this->get_stats(); + $this->assertEquals( $stats, $stats2, 'Statistics data should be the same after aggregation' ); + // Check one single row explicitly. + $this->assertEquals( + 3, + $wpdb->get_var( + $wpdb->prepare( + "SELECT hits FROM `$wpdb->statify` WHERE created = %s AND referrer = %s AND target = %s", + $date->format( 'Y-m-d' ), + 'https://statify.pluginkollektiv.org/', + '/test/' + ) + ), + 'Unexpected hit count after aggregation' + ); + } } diff --git a/tests/test-tracking.php b/tests/test-tracking.php index bb4b11db..1c4c2c8a 100644 --- a/tests/test-tracking.php +++ b/tests/test-tracking.php @@ -361,10 +361,11 @@ function ( $data, $id ) use ( &$capture ) { $this->assertNotNull( $stats['visits'][0]['count'], 'Request not tracked' ); $this->assertNotEmpty( $capture, 'Hook stativy__visit_saved has not fired' ); $this->assertTrue( is_numeric( $capture['id'] ) && $capture['id'] > 0, 'unexpected entry ID' ); - $this->assertCount( 3, $capture['data'], 'unexpected number of data fields' ); + $this->assertCount( 4, $capture['data'], 'unexpected number of data fields' ); $this->assertEquals( ( new DateTime() )->format( 'Y-m-d' ), $capture['data']['created'], 'unexpected creation date' ); $this->assertEquals( 'https://statify.pluginkollektiv.org/', $capture['data']['referrer'], 'unexpected referrer' ); $this->assertEquals( '/page', $capture['data']['target'], 'unexpected target' ); + $this->assertEquals( 1, $capture['data']['hits'], 'unexpected hits' ); } /**